OCRRecordView.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Models\OCRRecord;
  4. use App\Models\OCRQuestionResult;
  5. use App\Jobs\ProcessOCRRecord;
  6. use Filament\Notifications\Notification;
  7. use Filament\Pages\Page;
  8. use Livewire\Attributes\Computed;
  9. class OCRRecordView extends Page
  10. {
  11. protected static ?string $title = 'OCR记录详情';
  12. protected static ?string $slug = 'ocr-record-view/{recordId}';
  13. protected string $view = 'filament.pages.ocr-record-view-new';
  14. public static function shouldRegisterNavigation(): bool
  15. {
  16. return false;
  17. }
  18. public string $recordId = '';
  19. public array $manualAnswers = [];
  20. public bool $hasAnalysisResults = false;
  21. // 新增:判卷相关
  22. public array $questionGrades = []; // 存储每道题的评分 [question_id => ['score' => x, 'is_correct' => true/false]]
  23. public bool $isGenerating = false;
  24. public ?string $generationTaskId = null;
  25. public array $questionGenerationStatus = [];
  26. #[Computed]
  27. public function record(): ?OCRRecord
  28. {
  29. return OCRRecord::with(['student', 'questions'])->find($this->recordId);
  30. }
  31. public function mount(string $recordId): void
  32. {
  33. $this->recordId = $recordId;
  34. $record = $this->record();
  35. if ($record) {
  36. // Fix stuck processing status: if status is processing but we have questions, it's actually completed
  37. if ($record->status === 'processing' && $record->questions()->count() > 0) {
  38. $record->update([
  39. 'status' => 'completed',
  40. 'processed_at' => $record->processed_at ?? now(),
  41. 'total_questions' => $record->questions()->count(),
  42. 'processed_questions' => $record->questions()->count(),
  43. ]);
  44. // Refresh record to get updated status
  45. $record = $this->record();
  46. }
  47. foreach ($record->questions as $question) {
  48. if ($question->manual_answer) {
  49. $this->manualAnswers[$question->id] = $question->manual_answer;
  50. }
  51. // 加载已有的评分
  52. if ($question->ai_score !== null || $question->score_value !== null) {
  53. $this->questionGrades[$question->id] = [
  54. 'score' => $question->ai_score ?? $question->score_value,
  55. 'is_correct' => $question->is_correct,
  56. ];
  57. }
  58. // 初始化生成状态
  59. $this->questionGenerationStatus[$question->id] = $question->generation_status ?? 'pending';
  60. if ($question->generation_status === 'generating' && $question->generation_task_id) {
  61. $this->isGenerating = true;
  62. $this->generationTaskId = $question->generation_task_id;
  63. }
  64. }
  65. // 检查是否已有AI分析结果
  66. $this->checkAnalysisResults($record);
  67. }
  68. }
  69. /**
  70. * Check if record already has AI analysis results
  71. */
  72. private function checkAnalysisResults(OCRRecord $record): void
  73. {
  74. // Only consider analyzed if ai_analyzed_at is set AND we have scores
  75. $this->hasAnalysisResults = $record->ai_analyzed_at && $record->questions()
  76. ->whereNotNull('ai_score')
  77. ->exists();
  78. }
  79. /**
  80. * 生成题库题目
  81. */
  82. public function generateQuestionBankQuestions(): void
  83. {
  84. $record = $this->record();
  85. if (!$record) return;
  86. $questionsToGenerate = [];
  87. foreach ($record->questions as $question) {
  88. // 只有未关联题库的题目才需要生成
  89. if (!$question->question_bank_id) {
  90. $questionsToGenerate[] = [
  91. 'id' => $question->question_number, // 使用id字段匹配ocr_question_number
  92. 'content' => $question->question_text,
  93. // 可以传递更多字段,如知识点等
  94. 'student_answer' => $question->student_answer,
  95. 'kp_code' => $question->kp_code,
  96. ];
  97. }
  98. }
  99. if (empty($questionsToGenerate)) {
  100. Notification::make()
  101. ->title('无需生成')
  102. ->body('所有题目已关联题库')
  103. ->success()
  104. ->send();
  105. return;
  106. }
  107. try {
  108. $service = app(\App\Services\QuestionBankService::class);
  109. // 使用异步API,让系统自动生成回调URL
  110. $response = $service->generateQuestionsFromOcrAsync(
  111. $questionsToGenerate,
  112. $record->student->grade ?? '高一', // 假设有年级字段
  113. '数学', // 默认科目
  114. $record->id, // OCR记录ID,用于关联
  115. null, // 让系统自动生成回调URL
  116. 'api.ocr.callback' // 回调路由名称
  117. );
  118. if ($response['status'] === 'processing' && isset($response['task_id'])) {
  119. $this->isGenerating = true;
  120. $this->generationTaskId = $response['task_id'];
  121. // 更新数据库状态为生成中
  122. foreach ($record->questions as $question) {
  123. if (!$question->question_bank_id) {
  124. $question->update([
  125. 'generation_status' => 'generating',
  126. 'generation_task_id' => $this->generationTaskId,
  127. 'generation_error' => null
  128. ]);
  129. $this->questionGenerationStatus[$question->id] = 'generating';
  130. }
  131. }
  132. Notification::make()
  133. ->title('开始生成')
  134. ->body('已提交题库题目生成任务,系统将在后台处理并自动关联结果...')
  135. ->success()
  136. ->send();
  137. } else {
  138. throw new \Exception($response['message'] ?? '未知错误');
  139. }
  140. } catch (\Exception $e) {
  141. Notification::make()
  142. ->title('生成失败')
  143. ->body($e->getMessage())
  144. ->danger()
  145. ->send();
  146. }
  147. }
  148. /**
  149. * 检查生成状态 (被轮询调用)
  150. */
  151. public function checkGenerationStatus(): void
  152. {
  153. if (!$this->isGenerating || !$this->generationTaskId) {
  154. return;
  155. }
  156. try {
  157. $service = app(\App\Services\QuestionBankService::class);
  158. $status = $service->checkGenerationTaskStatus($this->generationTaskId);
  159. if (($status['status'] ?? '') === 'completed') {
  160. $this->isGenerating = false;
  161. $results = $status['results'] ?? [];
  162. // 更新题目关联
  163. $record = $this->record();
  164. foreach ($record->questions as $question) {
  165. // 在结果中查找对应题目(假设通过 question_number 匹配)
  166. $result = collect($results)->firstWhere('question_number', $question->question_number);
  167. if ($result && isset($result['question_id'])) {
  168. $question->update([
  169. 'question_bank_id' => $result['question_id'],
  170. 'generation_status' => 'completed',
  171. 'generation_error' => null
  172. ]);
  173. $this->questionGenerationStatus[$question->id] = 'completed';
  174. } elseif ($question->generation_status === 'generating') {
  175. // 如果任务完成了但没找到这个题的结果,标记为失败
  176. $question->update([
  177. 'generation_status' => 'failed',
  178. 'generation_error' => '生成结果中未找到对应题目'
  179. ]);
  180. $this->questionGenerationStatus[$question->id] = 'failed';
  181. }
  182. }
  183. Notification::make()
  184. ->title('生成完成')
  185. ->body('题库题目已生成并关联')
  186. ->success()
  187. ->send();
  188. } elseif (($status['status'] ?? '') === 'failed') {
  189. $this->isGenerating = false;
  190. $record = $this->record();
  191. foreach ($record->questions as $question) {
  192. if ($question->generation_status === 'generating') {
  193. $question->update([
  194. 'generation_status' => 'failed',
  195. 'generation_error' => $status['message'] ?? '任务执行失败'
  196. ]);
  197. $this->questionGenerationStatus[$question->id] = 'failed';
  198. }
  199. }
  200. Notification::make()
  201. ->title('生成失败')
  202. ->body($status['message'] ?? '任务执行失败')
  203. ->danger()
  204. ->send();
  205. }
  206. } catch (\Exception $e) {
  207. \Log::error('检查生成状态失败: ' . $e->getMessage());
  208. }
  209. }
  210. /**
  211. * 检查是否可以提交分析
  212. */
  213. public function canSubmitAnalysis(): bool
  214. {
  215. $record = $this->record();
  216. if (!$record) return false;
  217. // 检查是否有题目未关联题库ID
  218. // 排除那些可能不需要生成的(如果有的话),这里假设所有OCR题目都需要进题库
  219. return !$record->questions()->whereNull('question_bank_id')->exists();
  220. }
  221. /**
  222. * Submit all questions for AI analysis.
  223. * Updates manual answers in batch, then sends data to LearningAnalytics using unified interface.
  224. */
  225. public function submitForAnalysis(): void
  226. {
  227. // 1. 检查是否所有题目都已关联题库
  228. if (!$this->canSubmitAnalysis()) {
  229. Notification::make()
  230. ->title('请先生成题库题目')
  231. ->body('分析前需要确保所有题目都已在题库中创建并关联')
  232. ->warning()
  233. ->send();
  234. return;
  235. }
  236. $record = $this->record();
  237. if (! $record) {
  238. Notification::make()
  239. ->title('记录不存在')
  240. ->danger()
  241. ->send();
  242. return;
  243. }
  244. $updatedCount = 0;
  245. foreach ($record->questions as $question) {
  246. $manualAnswer = $this->manualAnswers[$question->id] ?? null;
  247. if ($manualAnswer && trim($manualAnswer) !== '') {
  248. $question->update([
  249. 'manual_answer' => trim($manualAnswer),
  250. 'answer_verified' => true,
  251. ]);
  252. $updatedCount++;
  253. }
  254. }
  255. // 使用统一接口提交分析
  256. try {
  257. $learningService = app(\App\Services\LearningAnalyticsService::class);
  258. // 准备答题数据(与系统卷子格式一致)
  259. $answers = [];
  260. foreach ($record->questions as $question) {
  261. // 使用校准后的答案(manual_answer),如果没有则使用OCR识别的答案
  262. $studentAnswer = !empty(trim($question->manual_answer ?? ''))
  263. ? trim($question->manual_answer)
  264. : trim($question->student_answer ?? '');
  265. $answers[] = [
  266. 'question_bank_id' => $question->question_bank_id, // 使用真实的题库ID
  267. 'question_text' => $question->question_text ?? '',
  268. 'student_answer' => $studentAnswer,
  269. 'is_correct' => null, // 让AI判断
  270. 'score' => null,
  271. 'max_score' => $question->score_total ?? null,
  272. 'kp_code' => $question->kp_code ?? null,
  273. ];
  274. }
  275. // 提交到统一接口
  276. $submissionData = [
  277. 'paper_id' => 'ocr_' . $record->id,
  278. 'answers' => $answers,
  279. ];
  280. \Log::info('OCRRecordView提交分析(统一接口)', [
  281. 'record_id' => $record->id,
  282. 'student_id' => $record->student_id,
  283. 'question_count' => count($answers)
  284. ]);
  285. $response = $learningService->submitBatchAttempts($record->student_id, $submissionData);
  286. if (!empty($response) && !isset($response['error'])) {
  287. // 从响应中获取analysis_id
  288. $analysisId = $response['analysis_id'] ?? $response['data']['analysis_id'] ?? ('batch_' . $record->id . '_' . time());
  289. // 更新OCR记录的analysis_id和状态
  290. $record->update([
  291. 'analysis_id' => $analysisId,
  292. 'ai_analyzed_at' => now(),
  293. 'ai_analysis_count' => count($answers),
  294. ]);
  295. \Log::info('OCR分析提交成功', [
  296. 'record_id' => $record->id,
  297. 'analysis_id' => $analysisId
  298. ]);
  299. // 重新检查分析结果状态
  300. $this->checkAnalysisResults($record);
  301. Notification::make()
  302. ->title('分析完成')
  303. ->body("已更新 {$updatedCount} 道题的答案,已提交 " . count($answers) . " 道题目进行AI分析")
  304. ->success()
  305. ->send();
  306. // 跳转到分析页面
  307. $this->redirect("/admin/exam-analysis?recordId={$record->id}");
  308. } else {
  309. \Log::error('OCR分析提交失败', [
  310. 'record_id' => $record->id,
  311. 'response' => $response
  312. ]);
  313. Notification::make()
  314. ->title('分析失败')
  315. ->body('提交AI分析失败:' . ($response['message'] ?? '未知错误'))
  316. ->danger()
  317. ->send();
  318. }
  319. } catch (\Exception $e) {
  320. \Log::error('提交OCR分析异常', [
  321. 'record_id' => $record->id,
  322. 'error' => $e->getMessage(),
  323. 'trace' => $e->getTraceAsString()
  324. ]);
  325. Notification::make()
  326. ->title('分析失败')
  327. ->body('提交分析时发生异常:' . $e->getMessage())
  328. ->danger()
  329. ->send();
  330. }
  331. }
  332. public function startRecognition(): void
  333. {
  334. $record = $this->record();
  335. if (! $record) {
  336. Notification::make()
  337. ->title('记录不存在')
  338. ->danger()
  339. ->send();
  340. return;
  341. }
  342. if ($record->status === 'processing') {
  343. Notification::make()
  344. ->title('正在处理中')
  345. ->warning()
  346. ->send();
  347. return;
  348. }
  349. ProcessOCRRecord::dispatch($record);
  350. $record->update(['status' => 'processing']);
  351. Notification::make()
  352. ->title('开始识别')
  353. ->body('OCR识别任务已启动,请稍后刷新查看结果')
  354. ->success()
  355. ->send();
  356. }
  357. public function getStatusBadgeConfig(string $status): array
  358. {
  359. return match ($status) {
  360. 'pending' => ['class' => 'badge-warning', 'text' => '待处理'],
  361. 'processing' => ['class' => 'badge-info', 'text' => '处理中'],
  362. 'completed' => ['class' => 'badge-success', 'text' => '已完成'],
  363. 'failed' => ['class' => 'badge-error', 'text' => '失败'],
  364. default => ['class' => 'badge-ghost', 'text' => $status],
  365. };
  366. }
  367. }