|
@@ -0,0 +1,1150 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Filament\Pages;
|
|
|
|
|
+
|
|
|
|
|
+use App\Models\OCRRecord;
|
|
|
|
|
+use App\Models\OCRQuestionResult;
|
|
|
|
|
+use App\Services\LearningAnalyticsService;
|
|
|
|
|
+use App\Services\OCRService;
|
|
|
|
|
+use BackedEnum;
|
|
|
|
|
+use Filament\Notifications\Notification;
|
|
|
|
|
+use Filament\Pages\Page;
|
|
|
|
|
+use Livewire\Attributes\Url;
|
|
|
|
|
+use UnitEnum;
|
|
|
|
|
+
|
|
|
|
|
+class ExamAnalysis extends Page
|
|
|
|
|
+{
|
|
|
|
|
+ protected static ?string $title = '试卷分析详情';
|
|
|
|
|
+ protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
|
|
|
|
|
+ protected static ?string $navigationLabel = '试卷分析';
|
|
|
|
|
+ protected static string|UnitEnum|null $navigationGroup = '管理';
|
|
|
|
|
+ protected static ?int $navigationSort = 15;
|
|
|
|
|
+
|
|
|
|
|
+ #[Url]
|
|
|
|
|
+ public ?string $recordId = null; // OCR记录ID
|
|
|
|
|
+
|
|
|
|
|
+ #[Url]
|
|
|
|
|
+ public ?string $paperId = null; // 系统生成卷子ID
|
|
|
|
|
+
|
|
|
|
|
+ public array $recordData = [];
|
|
|
|
|
+ public array $analysisData = []; // 整体掌握度分析数据
|
|
|
|
|
+ public array $paperAnalysisData = []; // 本次试卷的分析结果
|
|
|
|
|
+ public array $studentInfo = [];
|
|
|
|
|
+ public bool $loading = true;
|
|
|
|
|
+ public string $recordType = ''; // 'ocr' 或 'generated'
|
|
|
|
|
+
|
|
|
|
|
+ public function mount()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 允许使用 recordId(OCR记录)或 paperId(系统生成卷子)
|
|
|
|
|
+ if (!$this->recordId && !$this->paperId) {
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('错误')
|
|
|
|
|
+ ->body('缺少记录ID或试卷ID')
|
|
|
|
|
+ ->danger()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+
|
|
|
|
|
+ $this->redirectRoute('filament.admin.pages.upload-exam-paper');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 根据记录类型选择不同的视图
|
|
|
|
|
+ if ($this->recordId) {
|
|
|
|
|
+ // OCR记录使用紧凑布局
|
|
|
|
|
+ $this->view = 'filament.pages.exam-analysis-compact';
|
|
|
|
|
+ } elseif ($this->paperId) {
|
|
|
|
|
+ // 系统生成卷子使用标准布局
|
|
|
|
|
+ $this->view = 'filament.pages.exam-analysis-standard';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->loadAnalysisData();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function loadAnalysisData()
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 处理OCR记录
|
|
|
|
|
+ if ($this->recordId) {
|
|
|
|
|
+ $this->recordType = 'ocr';
|
|
|
|
|
+ $record = OCRRecord::with('student')->find($this->recordId);
|
|
|
|
|
+
|
|
|
|
|
+ if (!$record) {
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('错误')
|
|
|
|
|
+ ->body('未找到指定的上传记录')
|
|
|
|
|
+ ->danger()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+
|
|
|
|
|
+ $this->redirectRoute('filament.admin.pages.upload-exam-paper');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->recordData = $record->toArray();
|
|
|
|
|
+ $this->studentInfo = $record->student ? $record->student->toArray() : [];
|
|
|
|
|
+
|
|
|
|
|
+ // OCR记录:添加题目统计信息
|
|
|
|
|
+ $ocrQuestionsCount = OCRQuestionResult::where('ocr_record_id', $this->recordId)->count();
|
|
|
|
|
+ $this->recordData['total_questions'] = $ocrQuestionsCount;
|
|
|
|
|
+ $this->recordData['questions'] = $this->getQuestions(); // 提前加载题目数据
|
|
|
|
|
+
|
|
|
|
|
+ // OCR记录如果已完成处理,加载分析数据
|
|
|
|
|
+ if ($record->status === 'completed' && $record->student_id) {
|
|
|
|
|
+ $this->loadLearningAnalysis($record->student_id, $this->recordId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $this->analysisData = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 处理系统生成卷子
|
|
|
|
|
+ elseif ($this->paperId) {
|
|
|
|
|
+ $this->recordType = 'generated';
|
|
|
|
|
+ $paper = \App\Models\Paper::with('student')->find($this->paperId);
|
|
|
|
|
+
|
|
|
|
|
+ if (!$paper) {
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('错误')
|
|
|
|
|
+ ->body('未找到指定的试卷')
|
|
|
|
|
+ ->danger()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+
|
|
|
|
|
+ $this->redirectRoute('filament.admin.pages.upload-exam-paper');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构造基础数据,视图会安全处理缺失字段
|
|
|
|
|
+ $this->recordData = [
|
|
|
|
|
+ 'id' => $paper->paper_id,
|
|
|
|
|
+ 'paper_id' => $paper->paper_id,
|
|
|
|
|
+ 'student_id' => $paper->student_id,
|
|
|
|
|
+ 'paper_type' => 'system_generated',
|
|
|
|
|
+ 'paper_name' => $paper->paper_name,
|
|
|
|
|
+ 'status' => $paper->status,
|
|
|
|
|
+ 'total_questions' => $paper->question_count,
|
|
|
|
|
+ 'created_at' => $paper->created_at,
|
|
|
|
|
+ 'analysis_id' => $paper->analysis_id, // AI分析记录ID
|
|
|
|
|
+ ];
|
|
|
|
|
+ $this->studentInfo = $paper->student ? $paper->student->toArray() : [];
|
|
|
|
|
+
|
|
|
|
|
+ // 获取试卷题目列表(包含题库API的详细数据)
|
|
|
|
|
+ $this->recordData['questions'] = $this->getQuestions();
|
|
|
|
|
+
|
|
|
|
|
+ // 系统生成卷子也尝试加载学习分析数据
|
|
|
|
|
+ if ($paper->student_id) {
|
|
|
|
|
+ $this->loadLearningAnalysis($paper->student_id, $this->paperId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $this->analysisData = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->loading = false;
|
|
|
|
|
+
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::error('加载试卷分析数据失败', [
|
|
|
|
|
+ 'record_id' => $this->recordId,
|
|
|
|
|
+ 'paper_id' => $this->paperId,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString()
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('错误')
|
|
|
|
|
+ ->body('加载分析数据失败:' . $e->getMessage())
|
|
|
|
|
+ ->danger()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+
|
|
|
|
|
+ $this->loading = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 加载学习分析数据
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function loadLearningAnalysis($studentId, $identifier)
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 1. 尝试从数据库加载本次试卷的分析结果
|
|
|
|
|
+ $paperId = null;
|
|
|
|
|
+ if ($this->recordType === 'ocr') {
|
|
|
|
|
+ $paperId = $this->recordData['exam_id'] ?? null;
|
|
|
|
|
+ } elseif ($this->recordType === 'generated') {
|
|
|
|
|
+ $paperId = $this->recordData['paper_id'] ?? null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($paperId) {
|
|
|
|
|
+ // 从 paper_analysis_results 表读取分析结果
|
|
|
|
|
+ $paperAnalysis = \DB::table('paper_analysis_results')
|
|
|
|
|
+ ->where('paper_id', $paperId)
|
|
|
|
|
+ ->first();
|
|
|
|
|
+
|
|
|
|
|
+ if ($paperAnalysis && !empty($paperAnalysis->analysis_data)) {
|
|
|
|
|
+ $this->paperAnalysisData = json_decode($paperAnalysis->analysis_data, true);
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('本次试卷分析结果已从数据库加载', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'data_keys' => array_keys($this->paperAnalysisData ?? [])
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 如果数据库中没有分析结果,回退到API调用(临时解决方案)
|
|
|
|
|
+ \Log::info('数据库中未找到分析结果,回退到API调用', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'student_id' => $studentId
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->loadLearningAnalysisFromAPI($studentId, $paperId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 尝试从知识点记录表获取整体掌握度数据
|
|
|
|
|
+ $knowledgePointRecords = \DB::table('knowledge_point_records')
|
|
|
|
|
+ ->where('student_id', $studentId)
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ // 处理知识点掌握度数据
|
|
|
|
|
+ if ($knowledgePointRecords->count() > 0) {
|
|
|
|
|
+ // 如果找到了知识点记录数据,使用它
|
|
|
|
|
+ $this->processKnowledgePointRecords($knowledgePointRecords);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 如果没有找到知识点记录,回退到API调用
|
|
|
|
|
+ \Log::info('知识点记录表为空,回退到API调用', [
|
|
|
|
|
+ 'student_id' => $studentId
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->loadLearningAnalysisFromAPI($studentId, $paperId ?? null);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::warning('加载分析数据失败,回退到API调用', [
|
|
|
|
|
+ 'identifier' => $identifier,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'type' => $this->recordType,
|
|
|
|
|
+ 'error' => $e->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 数据库查询失败时回退到API调用
|
|
|
|
|
+ $this->loadLearningAnalysisFromAPI($studentId, $paperId ?? null);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 从API加载学习分析数据(回退方案)
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function loadLearningAnalysisFromAPI($studentId, $paperId = null)
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ $learningService = app(\App\Services\LearningAnalyticsService::class);
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 加载本次试卷的分析结果
|
|
|
|
|
+ $analysisId = null;
|
|
|
|
|
+ if ($this->recordType === 'ocr' && isset($this->recordData['analysis_id'])) {
|
|
|
|
|
+ $analysisId = $this->recordData['analysis_id'];
|
|
|
|
|
+ } elseif ($this->recordType === 'generated' && isset($this->recordData['analysis_id'])) {
|
|
|
|
|
+ $analysisId = $this->recordData['analysis_id'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($analysisId) {
|
|
|
|
|
+ $paperAnalysisResponse = $learningService->getAnalysisResult($analysisId);
|
|
|
|
|
+ if (!empty($paperAnalysisResponse) && isset($paperAnalysisResponse['data'])) {
|
|
|
|
|
+ $this->paperAnalysisData = $paperAnalysisResponse['data'];
|
|
|
|
|
+
|
|
|
|
|
+ // 将API的分析结果同步到题目数据
|
|
|
|
|
+ if (isset($this->paperAnalysisData['question_results'])) {
|
|
|
|
|
+ $this->syncApiAnalysisToQuestions($this->paperAnalysisData['question_results']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('本次试卷分析结果已从API加载', [
|
|
|
|
|
+ 'analysis_id' => $analysisId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'data_keys' => array_keys($this->paperAnalysisData),
|
|
|
|
|
+ 'question_results_count' => count($this->paperAnalysisData['question_results'] ?? [])
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 调用学习分析API获取整体掌握度数据
|
|
|
|
|
+ $masteryResponse = $learningService->getStudentMastery($studentId);
|
|
|
|
|
+
|
|
|
|
|
+ // 转换为页面期望的格式
|
|
|
|
|
+ if (!empty($masteryResponse) && isset($masteryResponse['data'])) {
|
|
|
|
|
+ $masteryList = $masteryResponse['data'];
|
|
|
|
|
+
|
|
|
|
|
+ // 计算整体掌握度
|
|
|
|
|
+ $totalMastery = 0;
|
|
|
|
|
+ $count = count($masteryList);
|
|
|
|
|
+ $weakAreas = [];
|
|
|
|
|
+ $knowledgePoints = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($masteryList as $mastery) {
|
|
|
|
|
+ $masteryLevel = $mastery['mastery_level'] ?? 0;
|
|
|
|
|
+ $totalMastery += $masteryLevel;
|
|
|
|
|
+
|
|
|
|
|
+ // 识别薄弱知识点(掌握度 < 0.6)
|
|
|
|
|
+ if ($masteryLevel < 0.6) {
|
|
|
|
|
+ $weakAreas[] = [
|
|
|
|
|
+ 'kp_code' => $mastery['kp_code'],
|
|
|
|
|
+ 'mastery' => $masteryLevel
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构造知识点数据
|
|
|
|
|
+ $totalAttempts = $mastery['total_attempts'] ?? 0;
|
|
|
|
|
+ $correctAttempts = $mastery['correct_attempts'] ?? 0;
|
|
|
|
|
+ $accuracyRate = $totalAttempts > 0 ? $correctAttempts / $totalAttempts : 0;
|
|
|
|
|
+
|
|
|
|
|
+ $knowledgePoints[] = [
|
|
|
|
|
+ 'kp_code' => $mastery['kp_code'],
|
|
|
|
|
+ 'name' => $mastery['kp_code'], // TODO: 从知识图谱服务获取名称
|
|
|
|
|
+ 'mastery' => $masteryLevel,
|
|
|
|
|
+ 'mastery_level' => $masteryLevel, // 添加模板需要的字段
|
|
|
|
|
+ 'total_attempts' => $totalAttempts,
|
|
|
|
|
+ 'correct_attempts' => $correctAttempts,
|
|
|
|
|
+ 'accuracy_rate' => $accuracyRate
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $overallMastery = $count > 0 ? $totalMastery / $count : 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 生成学习建议
|
|
|
|
|
+ $recommendations = $this->generateRecommendations($overallMastery, $weakAreas, $knowledgePoints);
|
|
|
|
|
+
|
|
|
|
|
+ // 只显示与当前试卷相关的知识点
|
|
|
|
|
+ $currentPaperKps = $this->getCurrentPaperKnowledgePoints();
|
|
|
|
|
+ $currentPaperKpCodes = array_column($currentPaperKps, 'kp_code');
|
|
|
|
|
+ $filteredKnowledgePoints = array_filter($knowledgePoints, function($kp) use ($currentPaperKpCodes) {
|
|
|
|
|
+ return in_array($kp['kp_code'], $currentPaperKpCodes);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $this->analysisData = [
|
|
|
|
|
+ 'overall_mastery' => $overallMastery,
|
|
|
|
|
+ 'weak_areas' => array_filter($weakAreas, function($weak) use ($currentPaperKpCodes) {
|
|
|
|
|
+ return in_array($weak['kp_code'], $currentPaperKpCodes);
|
|
|
|
|
+ }),
|
|
|
|
|
+ 'knowledge_points' => $filteredKnowledgePoints, // 只显示当前试卷相关知识点
|
|
|
|
|
+ 'recommendations' => $recommendations,
|
|
|
|
|
+ 'total_knowledge_points' => count($filteredKnowledgePoints),
|
|
|
|
|
+ 'mastery_distribution' => $this->calculateMasteryDistribution($masteryList)
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('学习分析数据已从API加载', [
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'overall_mastery' => $overallMastery,
|
|
|
|
|
+ 'knowledge_points_count' => count($knowledgePoints),
|
|
|
|
|
+ 'filtered_knowledge_points_count' => count($filteredKnowledgePoints),
|
|
|
|
|
+ 'current_paper_kp_codes' => $currentPaperKpCodes,
|
|
|
|
|
+ 'weak_areas_count' => count($weakAreas)
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ \Log::info('API返回数据为空', [
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'type' => $this->recordType
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->analysisData = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $apiError) {
|
|
|
|
|
+ \Log::warning('API调用失败', [
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'type' => $this->recordType,
|
|
|
|
|
+ 'error' => $apiError->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // API调用失败时设置空数组,避免页面报错
|
|
|
|
|
+ $this->analysisData = [];
|
|
|
|
|
+ $this->paperAnalysisData = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 处理知识点记录数据
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function processKnowledgePointRecords($knowledgePointRecords)
|
|
|
|
|
+ {
|
|
|
|
|
+ // 根据实际的knowledge_point_records表结构处理数据
|
|
|
|
|
+ $masteryList = [];
|
|
|
|
|
+ $weakAreas = [];
|
|
|
|
|
+ $knowledgePoints = [];
|
|
|
|
|
+ $totalMastery = 0;
|
|
|
|
|
+ $count = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($knowledgePointRecords as $record) {
|
|
|
|
|
+ // knowledge_point_records表有不同的字段结构
|
|
|
|
|
+ $kpCode = $record->knowledge_point ?? '';
|
|
|
|
|
+ $masteryLevel = $record->mastery_after ?? $record->mastery_before ?? 0;
|
|
|
|
|
+
|
|
|
|
|
+ if (!empty($kpCode)) {
|
|
|
|
|
+ $masteryList[] = [
|
|
|
|
|
+ 'kp_code' => $kpCode,
|
|
|
|
|
+ 'mastery_level' => $masteryLevel
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $totalMastery += $masteryLevel;
|
|
|
|
|
+ $count++;
|
|
|
|
|
+
|
|
|
|
|
+ // 识别薄弱知识点(掌握度 < 0.6)
|
|
|
|
|
+ if ($masteryLevel < 0.6) {
|
|
|
|
|
+ $weakAreas[] = [
|
|
|
|
|
+ 'kp_code' => $kpCode,
|
|
|
|
|
+ 'mastery' => $masteryLevel
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构造知识点数据
|
|
|
|
|
+ $knowledgePoints[] = [
|
|
|
|
|
+ 'kp_code' => $kpCode,
|
|
|
|
|
+ 'name' => $kpCode,
|
|
|
|
|
+ 'mastery' => $masteryLevel,
|
|
|
|
|
+ 'mastery_level' => $masteryLevel, // 添加模板需要的字段
|
|
|
|
|
+ 'total_attempts' => 1, // 默认值
|
|
|
|
|
+ 'correct_attempts' => $masteryLevel > 0.5 ? 1 : 0, // 估算值
|
|
|
|
|
+ 'accuracy_rate' => $masteryLevel
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $overallMastery = $count > 0 ? $totalMastery / $count : 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 生成学习建议
|
|
|
|
|
+ $recommendations = $this->generateRecommendations($overallMastery, $weakAreas, $knowledgePoints);
|
|
|
|
|
+
|
|
|
|
|
+ // 只显示与当前试卷相关的知识点
|
|
|
|
|
+ $currentPaperKps = $this->getCurrentPaperKnowledgePoints();
|
|
|
|
|
+ $currentPaperKpCodes = array_column($currentPaperKps, 'kp_code');
|
|
|
|
|
+ $filteredKnowledgePoints = array_filter($knowledgePoints, function($kp) use ($currentPaperKpCodes) {
|
|
|
|
|
+ return in_array($kp['kp_code'], $currentPaperKpCodes);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $this->analysisData = [
|
|
|
|
|
+ 'overall_mastery' => $overallMastery,
|
|
|
|
|
+ 'weak_areas' => array_filter($weakAreas, function($weak) use ($currentPaperKpCodes) {
|
|
|
|
|
+ return in_array($weak['kp_code'], $currentPaperKpCodes);
|
|
|
|
|
+ }),
|
|
|
|
|
+ 'knowledge_points' => $filteredKnowledgePoints, // 只显示当前试卷相关知识点
|
|
|
|
|
+ 'recommendations' => $recommendations,
|
|
|
|
|
+ 'total_knowledge_points' => count($filteredKnowledgePoints),
|
|
|
|
|
+ 'mastery_distribution' => $this->calculateMasteryDistribution($masteryList)
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('学习分析数据已从knowledge_point_records加载', [
|
|
|
|
|
+ 'student_id' => $knowledgePointRecords->first()->student_id ?? 'unknown',
|
|
|
|
|
+ 'overall_mastery' => $overallMastery,
|
|
|
|
|
+ 'knowledge_points_count' => count($knowledgePoints),
|
|
|
|
|
+ 'filtered_knowledge_points_count' => count($filteredKnowledgePoints),
|
|
|
|
|
+ 'current_paper_kp_codes' => $currentPaperKpCodes,
|
|
|
|
|
+ 'weak_areas_count' => count($weakAreas)
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成学习建议
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function generateRecommendations($overallMastery, $weakAreas, $knowledgePoints)
|
|
|
|
|
+ {
|
|
|
|
|
+ $recommendations = [];
|
|
|
|
|
+
|
|
|
|
|
+ if ($overallMastery >= 0.8) {
|
|
|
|
|
+ $recommendations[] = '整体掌握情况良好,继续保持!可以尝试更有挑战性的题目。';
|
|
|
|
|
+ } elseif ($overallMastery >= 0.6) {
|
|
|
|
|
+ $recommendations[] = '基础掌握较好,建议加强薄弱知识点的练习。';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $recommendations[] = '需要系统复习基础知识,建议从简单题目开始逐步提升。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 针对薄弱知识点的建议
|
|
|
|
|
+ if (count($weakAreas) > 0) {
|
|
|
|
|
+ $weakKpCodes = array_slice(array_column($weakAreas, 'kp_code'), 0, 3);
|
|
|
|
|
+ $recommendations[] = '重点加强以下知识点: ' . implode('、', $weakKpCodes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 根据答题次数给建议
|
|
|
|
|
+ $lowAttempts = array_filter($knowledgePoints, function($kp) {
|
|
|
|
|
+ return ($kp['total_attempts'] ?? 0) < 5;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (count($lowAttempts) > 0) {
|
|
|
|
|
+ $recommendations[] = '部分知识点练习次数较少,建议增加练习量以巩固掌握。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $recommendations;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 计算掌握度分布
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function calculateMasteryDistribution($masteryList)
|
|
|
|
|
+ {
|
|
|
|
|
+ $distribution = [
|
|
|
|
|
+ 'high' => 0, // >= 0.7
|
|
|
|
|
+ 'medium' => 0, // 0.4 - 0.7
|
|
|
|
|
+ 'low' => 0 // < 0.4
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($masteryList as $mastery) {
|
|
|
|
|
+ $level = $mastery['mastery_level'] ?? 0;
|
|
|
|
|
+ if ($level >= 0.7) {
|
|
|
|
|
+ $distribution['high']++;
|
|
|
|
|
+ } elseif ($level >= 0.4) {
|
|
|
|
|
+ $distribution['medium']++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $distribution['low']++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $distribution;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function getPaperTypeLabel(): string
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($this->recordType === 'ocr' && isset($this->recordData['paper_type'])) {
|
|
|
|
|
+ return match($this->recordData['paper_type']) {
|
|
|
|
|
+ 'unit_test' => '单元测试',
|
|
|
|
|
+ 'midterm' => '期中考试',
|
|
|
|
|
+ 'final' => '期末考试',
|
|
|
|
|
+ 'homework' => '家庭作业',
|
|
|
|
|
+ 'quiz' => '随堂测验',
|
|
|
|
|
+ 'other' => '其他',
|
|
|
|
|
+ default => '未分类',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return $this->recordData['paper_type'] ?? '未知';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function getStatusBadge(): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $status = $this->recordData['status'] ?? 'unknown';
|
|
|
|
|
+ return match($status) {
|
|
|
|
|
+ 'pending' => '<span class="badge badge-ghost">待处理</span>',
|
|
|
|
|
+ 'processing' => '<span class="badge badge-info gap-2"><span class="loading loading-spinner loading-xs"></span>处理中</span>',
|
|
|
|
|
+ 'completed' => '<span class="badge badge-success">已完成</span>',
|
|
|
|
|
+ 'failed' => '<span class="badge badge-error">失败</span>',
|
|
|
|
|
+ default => '<span class="badge badge-ghost">未知</span>',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 判断是否为 OCR 场景
|
|
|
|
|
+ */
|
|
|
|
|
+ public function isOcrRecord(): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ return $this->recordType === 'ocr';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取OCR记录的题目数据
|
|
|
|
|
+ * 从OCRQuestionResult表加载并格式化为组件期望的格式
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function getOcrQuestions(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $recordId = $this->recordId ?? null;
|
|
|
|
|
+ if (!$recordId) {
|
|
|
|
|
+ \Log::warning('OCR记录缺少recordId', ['recordId' => $recordId]);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 从OCRQuestionResult表加载题目数据
|
|
|
|
|
+ $ocrQuestions = OCRQuestionResult::where('ocr_record_id', $recordId)
|
|
|
|
|
+ ->orderBy('question_number')
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('加载OCR题目数据', [
|
|
|
|
|
+ 'record_id' => $recordId,
|
|
|
|
|
+ 'questions_count' => $ocrQuestions->count()
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ if ($ocrQuestions->isEmpty()) {
|
|
|
|
|
+ \Log::warning('OCR记录没有题目数据', ['record_id' => $recordId]);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 创建API分析结果的映射(如果有)
|
|
|
|
|
+ $analysisMap = [];
|
|
|
|
|
+ if (!empty($this->paperAnalysisData['question_results'])) {
|
|
|
|
|
+ foreach ($this->paperAnalysisData['question_results'] as $result) {
|
|
|
|
|
+ if (isset($result['question_id'])) {
|
|
|
|
|
+ $analysisMap[$result['question_id']] = $result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questions = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($ocrQuestions as $oq) {
|
|
|
|
|
+ // 获取API分析结果(如果有)
|
|
|
|
|
+ $aiAnalysis = null;
|
|
|
|
|
+ if (isset($analysisMap[$oq->question_number])) {
|
|
|
|
|
+ $analysis = $analysisMap[$oq->question_number];
|
|
|
|
|
+ $aiAnalysis = [
|
|
|
|
|
+ 'analysis' => $analysis['reason'] ?? '',
|
|
|
|
|
+ 'mistake_type' => $analysis['mistake_type'] ?? '',
|
|
|
|
|
+ 'mistake_category' => $analysis['mistake_category'] ?? '',
|
|
|
|
|
+ 'suggestions' => [$analysis['suggestions'] ?? ''],
|
|
|
|
|
+ 'correct_solution' => $analysis['correct_solution'] ?? '',
|
|
|
|
|
+ ];
|
|
|
|
|
+ } elseif (!empty($oq->ai_feedback)) {
|
|
|
|
|
+ // 如果没有API分析,使用OCR记录的AI反馈
|
|
|
|
|
+ $aiAnalysis = [
|
|
|
|
|
+ 'analysis' => $oq->ai_feedback,
|
|
|
|
|
+ 'mistake_type' => '',
|
|
|
|
|
+ 'mistake_category' => '',
|
|
|
|
|
+ 'suggestions' => [$oq->ai_feedback],
|
|
|
|
|
+ 'correct_solution' => '',
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 学生答案:优先使用老师校准的答案(manual_answer),如果没有则使用OCR识别的答案
|
|
|
|
|
+ $ocrAnswer = trim($oq->student_answer ?? '');
|
|
|
|
|
+ $manualAnswer = trim($oq->manual_answer ?? '');
|
|
|
|
|
+ $studentAnswer = !empty($manualAnswer) ? $manualAnswer : $ocrAnswer;
|
|
|
|
|
+
|
|
|
|
|
+ // 判断是否有答案(OCR识别或老师校准)
|
|
|
|
|
+ $hasAnswer = !empty($studentAnswer);
|
|
|
|
|
+ $displayAnswer = $hasAnswer ? ($studentAnswer ?: '空') : '未作答';
|
|
|
|
|
+
|
|
|
|
|
+ // 从AI分析结果中获取正确答案和判断
|
|
|
|
|
+ $correctAnswer = null;
|
|
|
|
|
+ $isCorrect = false;
|
|
|
|
|
+ if (isset($analysisMap[$oq->question_number])) {
|
|
|
|
|
+ $analysis = $analysisMap[$oq->question_number];
|
|
|
|
|
+ $correctAnswer = $analysis['correct_answer'] ?? $analysis['correct_solution'] ?? null;
|
|
|
|
|
+ $isCorrect = $analysis['is_correct'] ?? false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 显示答案对比(如果有AI分析的正确答案)
|
|
|
|
|
+ $answerComparison = null;
|
|
|
|
|
+ if (!empty($correctAnswer) && $studentAnswer !== $correctAnswer && $hasAnswer) {
|
|
|
|
|
+ $answerComparison = [
|
|
|
|
|
+ 'student' => $studentAnswer,
|
|
|
|
|
+ 'correct' => $correctAnswer
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questions[] = [
|
|
|
|
|
+ 'id' => $oq->id,
|
|
|
|
|
+ 'question_number' => $oq->question_number,
|
|
|
|
|
+ 'question_bank_id' => 'ocr_' . $oq->question_number, // OCR题目没有题库ID,用前缀标识
|
|
|
|
|
+ 'question_type' => 'unknown',
|
|
|
|
|
+ 'question_text' => $oq->question_text ?? '题目内容缺失',
|
|
|
|
|
+ 'content' => $oq->question_text ?? '题目内容缺失',
|
|
|
|
|
+ 'stem' => $oq->question_text ?? '题目内容缺失',
|
|
|
|
|
+ 'answer' => $correctAnswer ?? '', // 正确答案(从AI分析获取)
|
|
|
|
|
+ 'reference_answer' => $correctAnswer ?? '',
|
|
|
|
|
+ 'score_total' => $oq->score_total ?? null, // OCR题目的分数可能为空
|
|
|
|
|
+ 'score_obtained' => $oq->score_obtained ?? null, // OCR题目的分数可能为空
|
|
|
|
|
+ 'student_answer' => $displayAnswer, // 学生答案:未作答/空/实际答案(校准后)
|
|
|
|
|
+ 'is_correct' => $isCorrect,
|
|
|
|
|
+ 'kp_code' => $oq->kp_code ?? null, // OCR题目的知识点可能为空
|
|
|
|
|
+ 'ai_analysis' => $aiAnalysis,
|
|
|
|
|
+ 'answer_comparison' => $answerComparison, // 答案对比信息
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('OCR题目数据格式化完成', [
|
|
|
|
|
+ 'record_id' => $recordId,
|
|
|
|
|
+ 'formatted_questions_count' => count($questions),
|
|
|
|
|
+ 'has_ai_analysis_count' => count(array_filter($questions, fn($q) => !empty($q['ai_analysis'])))
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ return $questions;
|
|
|
|
|
+
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::error('获取OCR题目数据失败', [
|
|
|
|
|
+ 'record_id' => $recordId,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString()
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取题目列表(根据场景返回不同数据,包含AI分析解析)
|
|
|
|
|
+ */
|
|
|
|
|
+ public function getQuestions(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ // OCR记录:从OCRQuestionResult表加载题目数据
|
|
|
|
|
+ if ($this->recordType === 'ocr') {
|
|
|
|
|
+ return $this->getOcrQuestions();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 系统生成卷子:从PaperQuestion表加载题目数据
|
|
|
|
|
+ $paperId = $this->recordData['paper_id'] ?? null;
|
|
|
|
|
+ if (!$paperId) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 直接查询题目数据
|
|
|
|
|
+ $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paperId)
|
|
|
|
|
+ ->orderBy('question_number')
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ if ($paperQuestions->isEmpty()) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取题库题目详情
|
|
|
|
|
+ $questionBankService = app(\App\Services\QuestionBankService::class);
|
|
|
|
|
+ $questionBankIds = $paperQuestions->pluck('question_bank_id')->unique()->filter()->toArray();
|
|
|
|
|
+ $questionDetails = collect([]);
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('准备调用题库服务', [
|
|
|
|
|
+ 'question_bank_ids' => $questionBankIds,
|
|
|
|
|
+ 'ids_count' => count($questionBankIds)
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ if (!empty($questionBankIds)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ \Log::info('开始调用题库服务getQuestionsByIds');
|
|
|
|
|
+ $details = $questionBankService->getQuestionsByIds($questionBankIds);
|
|
|
|
|
+ \Log::info('题库服务调用结果', [
|
|
|
|
|
+ 'response' => $details,
|
|
|
|
|
+ 'has_data' => isset($details['data']),
|
|
|
|
|
+ 'data_type' => gettype($details['data'] ?? null)
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ if (isset($details['data']) && is_array($details['data'])) {
|
|
|
|
|
+ $questionDetails = collect($details['data'])->keyBy('id');
|
|
|
|
|
+ \Log::info('题库数据处理成功', [
|
|
|
|
|
+ 'details_count' => $questionDetails->count(),
|
|
|
|
|
+ 'first_key' => $questionDetails->keys()->first()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ \Log::warning('题库服务返回数据格式不正确', ['details' => $details]);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::error('获取题库数据失败', [
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ \Log::info('没有有效的题库ID');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构建题目数据 - 合并paper_questions表和题库API数据
|
|
|
|
|
+ $questions = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 创建API分析结果的映射(如果有)
|
|
|
|
|
+ $analysisMap = [];
|
|
|
|
|
+ if (!empty($this->paperAnalysisData['question_results'])) {
|
|
|
|
|
+ foreach ($this->paperAnalysisData['question_results'] as $result) {
|
|
|
|
|
+ if (isset($result['question_id'])) {
|
|
|
|
|
+ $analysisMap[$result['question_id']] = $result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($paperQuestions as $pq) {
|
|
|
|
|
+ // 从题库API获取详细数据
|
|
|
|
|
+ $detail = $questionDetails->get($pq->question_bank_id);
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('构建题目数据', [
|
|
|
|
|
+ 'question_bank_id' => $pq->question_bank_id,
|
|
|
|
|
+ 'has_detail' => $detail ? 'Yes' : 'No',
|
|
|
|
|
+ 'detail_id' => $detail['id'] ?? 'null',
|
|
|
|
|
+ 'stem_preview' => $detail ? substr($detail['stem'] ?? '', 50) : 'null',
|
|
|
|
|
+ 'kp_code_from_db' => $pq->knowledge_point,
|
|
|
|
|
+ 'kp_code_from_api' => $detail['kp_code'] ?? 'null',
|
|
|
|
|
+ 'has_analysis' => isset($analysisMap[$pq->question_bank_id]) ? 'Yes' : 'No'
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 优先使用题库API返回的stem,如果没有则使用paper_questions中的question_text
|
|
|
|
|
+ $questionText = $detail['stem'] ?? $detail['content'] ?? $pq->question_text ?? '题目内容缺失';
|
|
|
|
|
+ // 优先使用题库API返回的kp_code,如果没有则使用paper_questions中的knowledge_point
|
|
|
|
|
+ $kpCode = $detail['kp_code'] ?? $pq->knowledge_point ?? 'N/A';
|
|
|
|
|
+
|
|
|
|
|
+ // 获取API分析结果(如果有)
|
|
|
|
|
+ $aiAnalysis = null;
|
|
|
|
|
+ if (isset($analysisMap[$pq->question_bank_id])) {
|
|
|
|
|
+ $analysis = $analysisMap[$pq->question_bank_id];
|
|
|
|
|
+ $aiAnalysis = [
|
|
|
|
|
+ 'analysis' => $analysis['reason'] ?? '',
|
|
|
|
|
+ 'mistake_type' => $analysis['mistake_type'] ?? '',
|
|
|
|
|
+ 'mistake_category' => $analysis['mistake_category'] ?? '',
|
|
|
|
|
+ 'suggestions' => [$analysis['suggestions'] ?? ''],
|
|
|
|
|
+ 'correct_solution' => $analysis['correct_solution'] ?? '',
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questions[] = [
|
|
|
|
|
+ 'id' => $pq->id,
|
|
|
|
|
+ 'question_number' => $pq->question_number,
|
|
|
|
|
+ 'question_bank_id' => $pq->question_bank_id,
|
|
|
|
|
+ 'question_type' => $pq->question_type,
|
|
|
|
|
+ 'question_text' => $questionText,
|
|
|
|
|
+ 'content' => $questionText,
|
|
|
|
|
+ 'stem' => $questionText,
|
|
|
|
|
+ 'answer' => $detail['answer'] ?? '',
|
|
|
|
|
+ 'reference_answer' => $detail['answer'] ?? '',
|
|
|
|
|
+ 'score_total' => $pq->score ?? 5,
|
|
|
|
|
+ 'score_obtained' => $pq->score_obtained ?? 0,
|
|
|
|
|
+ 'student_answer' => $pq->student_answer ?? '未作答',
|
|
|
|
|
+ 'is_correct' => $pq->is_correct ?? false,
|
|
|
|
|
+ 'kp_code' => $kpCode,
|
|
|
|
|
+ 'ai_analysis' => $aiAnalysis,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $questions;
|
|
|
|
|
+
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::error('获取题目列表失败', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'error' => $e->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 重新处理OCR
|
|
|
|
|
+ */
|
|
|
|
|
+ public function reprocessOCR()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!$this->recordId) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $record = OCRRecord::find($this->recordId);
|
|
|
|
|
+ if (!$record) {
|
|
|
|
|
+ throw new \Exception('记录不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $ocrService = app(OCRService::class);
|
|
|
|
|
+ $ocrService->reprocess($record);
|
|
|
|
|
+
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('已提交重新处理')
|
|
|
|
|
+ ->body('OCR识别任务已重新加入队列')
|
|
|
|
|
+ ->success()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+
|
|
|
|
|
+ $this->loadAnalysisData(); // 刷新数据
|
|
|
|
|
+
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('操作失败')
|
|
|
|
|
+ ->body($e->getMessage())
|
|
|
|
|
+ ->danger()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 重新提交分析
|
|
|
|
|
+ */
|
|
|
|
|
+ public function reanalyze()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!$this->recordId) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $record = OCRRecord::find($this->recordId);
|
|
|
|
|
+ if (!$record) {
|
|
|
|
|
+ throw new \Exception('记录不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取当前的题目数据(包含可能的人工校准)
|
|
|
|
|
+ $questions = OCRQuestionResult::where('ocr_record_id', $this->recordId)
|
|
|
|
|
+ ->orderBy('question_number')
|
|
|
|
|
+ ->get()
|
|
|
|
|
+ ->map(function ($q) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question_number' => $q->question_number,
|
|
|
|
|
+ 'content' => $q->question_text,
|
|
|
|
|
+ 'student_answer' => $q->student_answer,
|
|
|
|
|
+ 'manual_answer' => $q->manual_answer,
|
|
|
|
|
+ 'answer_verified' => $q->answer_verified,
|
|
|
|
|
+ 'confidence' => $q->score_confidence,
|
|
|
|
|
+ 'kp_code' => $q->kp_code,
|
|
|
|
|
+ 'score_value' => $q->score_value,
|
|
|
|
|
+ ];
|
|
|
|
|
+ })->toArray();
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($questions)) {
|
|
|
|
|
+ throw new \Exception('没有可分析的题目数据');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构造分析请求数据
|
|
|
|
|
+ $analysisData = [
|
|
|
|
|
+ 'exam_id' => $record->exam_id,
|
|
|
|
|
+ 'student_id' => $record->student_id,
|
|
|
|
|
+ 'ocr_record_id' => $record->id,
|
|
|
|
|
+ 'teacher_name' => auth()->user()->name ?? 'Teacher',
|
|
|
|
|
+ 'analysis_type' => 'mastery',
|
|
|
|
|
+ 'questions' => array_map(function($q) {
|
|
|
|
|
+ // 优先使用人工校准的答案
|
|
|
|
|
+ $studentAnswer = $q['student_answer'] ?? '';
|
|
|
|
|
+ if (isset($q['manual_answer']) && !empty($q['manual_answer'])) {
|
|
|
|
|
+ $studentAnswer = $q['manual_answer'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question_id' => $q['question_number'],
|
|
|
|
|
+ 'question_number' => (string)$q['question_number'],
|
|
|
|
|
+ 'kp_code' => $q['kp_code'] ?? null,
|
|
|
|
|
+ 'score_value' => $q['score_value'] ?? 0,
|
|
|
|
|
+ 'student_answer' => $studentAnswer,
|
|
|
|
|
+ 'ocr_confidence' => $q['confidence'] ?? 0,
|
|
|
|
|
+ 'question_text' => $q['content'] ?? '',
|
|
|
|
|
+ 'teacher_validated' => $q['answer_verified'] ?? false,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }, $questions)
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // 调用分析服务
|
|
|
|
|
+ $learningService = app(LearningAnalyticsService::class);
|
|
|
|
|
+ $result = $learningService->submitOCRAnalysis($analysisData);
|
|
|
|
|
+
|
|
|
|
|
+ if (isset($result['success']) && $result['success']) {
|
|
|
|
|
+ $record->update([
|
|
|
|
|
+ 'ai_analyzed_at' => now(),
|
|
|
|
|
+ 'ai_analysis_count' => ($record->ai_analysis_count ?? 0) + 1
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('分析请求已提交')
|
|
|
|
|
+ ->body('系统正在重新分析试卷,请稍后刷新查看结果')
|
|
|
|
|
+ ->success()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+
|
|
|
|
|
+ $this->loadAnalysisData(); // 刷新数据
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw new \Exception($result['message'] ?? '提交分析失败');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title('操作失败')
|
|
|
|
|
+ ->body($e->getMessage())
|
|
|
|
|
+ ->danger()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 从当前试卷数据中提取知识点信息
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function extractKnowledgePointsFromCurrentPaper(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $knowledgePoints = [];
|
|
|
|
|
+
|
|
|
|
|
+ $questions = $this->getQuestions();
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($questions as $question) {
|
|
|
|
|
+ $kpCode = $question['kp_code'] ?? null;
|
|
|
|
|
+ if ($kpCode && $kpCode !== 'N/A') {
|
|
|
|
|
+ $isCorrect = $question['is_correct'] ?? false;
|
|
|
|
|
+
|
|
|
|
|
+ if (!isset($knowledgePoints[$kpCode])) {
|
|
|
|
|
+ $knowledgePoints[$kpCode] = [
|
|
|
|
|
+ 'kp_code' => $kpCode,
|
|
|
|
|
+ 'name' => $kpCode,
|
|
|
|
|
+ 'total_attempts' => 0,
|
|
|
|
|
+ 'correct_attempts' => 0,
|
|
|
|
|
+ 'mastery_level' => 0,
|
|
|
|
|
+ 'accuracy_rate' => 0
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $knowledgePoints[$kpCode]['total_attempts']++;
|
|
|
|
|
+ if ($isCorrect) {
|
|
|
|
|
+ $knowledgePoints[$kpCode]['correct_attempts']++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 计算准确率和掌握度
|
|
|
|
|
+ foreach ($knowledgePoints as &$kp) {
|
|
|
|
|
+ if ($kp['total_attempts'] > 0) {
|
|
|
|
|
+ $kp['accuracy_rate'] = $kp['correct_attempts'] / $kp['total_attempts'];
|
|
|
|
|
+ $kp['mastery_level'] = $kp['accuracy_rate'];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return array_values($knowledgePoints);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取当前试卷的知识点掌握情况
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function getCurrentPaperKnowledgePoints(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ // 首先尝试从当前试卷数据中提取
|
|
|
|
|
+ $currentPaperKps = $this->extractKnowledgePointsFromCurrentPaper();
|
|
|
|
|
+
|
|
|
|
|
+ // 如果有历史分析数据,合并以提供更准确的掌握度
|
|
|
|
|
+ if (!empty($this->analysisData['knowledge_points'])) {
|
|
|
|
|
+ $historicalKps = $this->analysisData['knowledge_points'];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($currentPaperKps as &$currentKp) {
|
|
|
|
|
+ $kpCode = $currentKp['kp_code'];
|
|
|
|
|
+
|
|
|
|
|
+ // 查找历史数据
|
|
|
|
|
+ $historicalData = collect($historicalKps)->firstWhere('kp_code', $kpCode);
|
|
|
|
|
+
|
|
|
|
|
+ if ($historicalData && isset($historicalData['mastery_level'])) {
|
|
|
|
|
+ // 使用历史数据的掌握度,但保留试卷的实际表现
|
|
|
|
|
+ $currentKp['historical_mastery_level'] = $historicalData['mastery_level'];
|
|
|
|
|
+ $currentKp['total_attempts'] = $historicalData['total_attempts'] ?? $currentKp['total_attempts'];
|
|
|
|
|
+ $currentKp['correct_attempts'] = $historicalData['correct_attempts'] ?? $currentKp['correct_attempts'];
|
|
|
|
|
+ $currentKp['accuracy_rate'] = $historicalData['accuracy_rate'] ?? $currentKp['accuracy_rate'];
|
|
|
|
|
+
|
|
|
|
|
+ // 优先使用历史的掌握度,但考虑试卷表现做微调
|
|
|
|
|
+ $paperPerformance = $currentKp['accuracy_rate'];
|
|
|
|
|
+ $historicalPerformance = $historicalData['mastery_level'] ?? 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 综合计算:70%历史 + 30%本试卷
|
|
|
|
|
+ $currentKp['mastery_level'] = ($historicalPerformance * 0.7 + $paperPerformance * 0.3);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $currentPaperKps;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 将API的分析结果同步到题目数据和数据库
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function syncApiAnalysisToQuestions(array $questionResults)
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ \Log::info('开始同步API分析结果到题目数据', [
|
|
|
|
|
+ 'paper_id' => $this->paperId,
|
|
|
|
|
+ 'question_results_count' => count($questionResults)
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 创建question_id到分析结果的映射
|
|
|
|
|
+ $analysisMap = [];
|
|
|
|
|
+ foreach ($questionResults as $result) {
|
|
|
|
|
+ $questionId = $result['question_id'] ?? null;
|
|
|
|
|
+ if ($questionId) {
|
|
|
|
|
+ $analysisMap[$questionId] = $result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取当前试卷的题目
|
|
|
|
|
+ $paper = \App\Models\Paper::find($this->paperId);
|
|
|
|
|
+ if ($paper) {
|
|
|
|
|
+ $paperQuestions = $paper->questions()->get();
|
|
|
|
|
+ $updatedCount = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($paperQuestions as $pq) {
|
|
|
|
|
+ // 查找对应的API分析结果
|
|
|
|
|
+ if (isset($analysisMap[$pq->question_bank_id])) {
|
|
|
|
|
+ $analysis = $analysisMap[$pq->question_bank_id];
|
|
|
|
|
+
|
|
|
|
|
+ // 更新数据库字段
|
|
|
|
|
+ $pq->is_correct = $analysis['correct'] ?? false;
|
|
|
|
|
+ $pq->score_obtained = $analysis['score'] ?? 0;
|
|
|
|
|
+ $pq->save();
|
|
|
|
|
+
|
|
|
|
|
+ $updatedCount++;
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('更新题目分析结果', [
|
|
|
|
|
+ 'question_bank_id' => $pq->question_bank_id,
|
|
|
|
|
+ 'is_correct' => $pq->is_correct,
|
|
|
|
|
+ 'score_obtained' => $pq->score_obtained
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('API分析结果同步完成', [
|
|
|
|
|
+ 'updated_count' => $updatedCount,
|
|
|
|
|
+ 'total_questions' => $paperQuestions->count()
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 刷新recordData中的questions数据
|
|
|
|
|
+ $this->recordData['questions'] = $this->getQuestions();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::error('同步API分析结果失败', [
|
|
|
|
|
+ 'paper_id' => $this->paperId,
|
|
|
|
|
+ 'error' => $e->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 提交OCR记录到AI分析API(与系统卷子使用统一接口)
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function submitOcrForAnalysis($record)
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 获取OCR题目结果(使用校准后的答案)
|
|
|
|
|
+ $ocrQuestions = OCRQuestionResult::where('ocr_record_id', $record->id)
|
|
|
|
|
+ ->orderBy('question_number')
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ if ($ocrQuestions->isEmpty()) {
|
|
|
|
|
+ \Log::warning('OCR记录没有题目,无法提交分析', ['record_id' => $record->id]);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 构建答题数据(与系统卷子格式一致)
|
|
|
|
|
+ $answers = [];
|
|
|
|
|
+ foreach ($ocrQuestions as $oq) {
|
|
|
|
|
+ // 使用校准后的答案(manual_answer),如果没有则使用OCR识别的答案
|
|
|
|
|
+ $studentAnswer = !empty(trim($oq->manual_answer ?? ''))
|
|
|
|
|
+ ? trim($oq->manual_answer)
|
|
|
|
|
+ : trim($oq->student_answer ?? '');
|
|
|
|
|
+
|
|
|
|
|
+ $answers[] = [
|
|
|
|
|
+ 'question_bank_id' => 'ocr_q' . $oq->question_number, // OCR题目没有题库ID,生成临时ID
|
|
|
|
|
+ 'question_text' => $oq->question_text ?? '', // 添加题目内容
|
|
|
|
|
+ 'student_answer' => $studentAnswer,
|
|
|
|
|
+ 'is_correct' => null, // 让AI分析判断
|
|
|
|
|
+ 'score' => null, // OCR题目可能没有分数
|
|
|
|
|
+ 'max_score' => $oq->score_total ?? null,
|
|
|
|
|
+ 'kp_code' => $oq->kp_code ?? null,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 使用与系统卷子相同的接口提交
|
|
|
|
|
+ $submissionData = [
|
|
|
|
|
+ 'paper_id' => 'ocr_' . $record->id, // OCR记录ID作为paper_id
|
|
|
|
|
+ 'answers' => $answers,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('提交OCR数据到AI分析(统一接口)', [
|
|
|
|
|
+ 'record_id' => $record->id,
|
|
|
|
|
+ 'student_id' => $record->student_id,
|
|
|
|
|
+ 'question_count' => count($answers),
|
|
|
|
|
+ 'api_endpoint' => '/api/v1/attempts/batch/student/' . $record->student_id
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 调用学习分析服务(与系统卷子使用相同的方法)
|
|
|
|
|
+ $learningService = app(\App\Services\LearningAnalyticsService::class);
|
|
|
|
|
+ $response = $learningService->submitBatchAttempts($record->student_id, $submissionData);
|
|
|
|
|
+
|
|
|
|
|
+ if (!empty($response) && !isset($response['error'])) {
|
|
|
|
|
+ // 从响应中获取analysis_id(如果API返回)
|
|
|
|
|
+ $analysisId = $response['analysis_id'] ?? $response['data']['analysis_id'] ?? ('batch_' . $record->id . '_' . time());
|
|
|
|
|
+
|
|
|
|
|
+ // 更新OCR记录的analysis_id
|
|
|
|
|
+ $record->analysis_id = $analysisId;
|
|
|
|
|
+ $record->save();
|
|
|
|
|
+
|
|
|
|
|
+ \Log::info('OCR分析提交成功(统一接口)', [
|
|
|
|
|
+ 'record_id' => $record->id,
|
|
|
|
|
+ 'analysis_id' => $analysisId,
|
|
|
|
|
+ 'response_keys' => array_keys($response)
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新recordData
|
|
|
|
|
+ $this->recordData['analysis_id'] = $analysisId;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ \Log::error('OCR分析提交失败', [
|
|
|
|
|
+ 'record_id' => $record->id,
|
|
|
|
|
+ 'response' => $response
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ \Log::error('提交OCR分析异常', [
|
|
|
|
|
+ 'record_id' => $record->id,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|