OCRPaperAnalysisView.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Models\OCRRecord;
  4. use App\Models\OCRQuestionResult;
  5. use App\Models\Paper;
  6. use App\Models\PaperQuestion;
  7. use Filament\Notifications\Notification;
  8. use Filament\Pages\Page;
  9. use Livewire\Attributes\Computed;
  10. use Livewire\Attributes\On;
  11. class OCRPaperAnalysisView extends Page
  12. {
  13. protected static ?string $title = '试卷答题分析';
  14. protected static ?string $slug = 'ocr-paper-analysis/{recordId}';
  15. // 不在导航中显示,通过链接直接访问
  16. protected string $view = 'filament.pages.ocr-paper-analysis';
  17. public static function shouldRegisterNavigation(): bool
  18. {
  19. return false; // 不显示在导航菜单中,通过链接直接访问
  20. }
  21. public string $recordId = '';
  22. public ?Paper $paper = null;
  23. public ?OCRRecord $ocrRecord = null;
  24. public array $matchedQuestions = [];
  25. public array $analysisResults = [];
  26. public bool $isAnalyzing = false;
  27. #[Computed]
  28. public function paperInfo(): array
  29. {
  30. if (!$this->paper) return [];
  31. return [
  32. 'paper_id' => $this->paper->paper_id,
  33. 'paper_name' => $this->paper->paper_name,
  34. 'student_id' => $this->paper->student_id,
  35. 'teacher_id' => $this->paper->teacher_id,
  36. 'total_questions' => $this->paper->total_questions,
  37. 'total_score' => $this->paper->total_score,
  38. 'status' => $this->paper->status,
  39. 'created_at' => $this->paper->created_at->format('Y-m-d H:i'),
  40. ];
  41. }
  42. #[Computed]
  43. public function studentInfo(): ?array
  44. {
  45. if (!$this->paper || !$this->paper->student_id) return null;
  46. $student = \App\Models\Student::find($this->paper->student_id);
  47. if (!$student) return null;
  48. return [
  49. 'student_id' => $student->student_id,
  50. 'name' => $student->name,
  51. 'grade' => $student->grade,
  52. 'class' => $student->class_name,
  53. ];
  54. }
  55. public function mount(string $recordId): void
  56. {
  57. $this->recordId = $recordId;
  58. $this->loadOCRRecord();
  59. if ($this->ocrRecord && $this->ocrRecord->analysis_id) {
  60. $this->loadPaper();
  61. if ($this->paper) {
  62. $this->matchQuestions();
  63. }
  64. }
  65. }
  66. /**
  67. * 加载OCR记录
  68. */
  69. private function loadOCRRecord(): void
  70. {
  71. $this->ocrRecord = OCRRecord::with(['student', 'questions'])
  72. ->where('id', $this->recordId)
  73. ->first();
  74. if (!$this->ocrRecord) {
  75. Notification::make()
  76. ->title('错误')
  77. ->body('OCR记录不存在')
  78. ->danger()
  79. ->send();
  80. return;
  81. }
  82. }
  83. /**
  84. * 加载关联的试卷
  85. */
  86. private function loadPaper(): void
  87. {
  88. $this->paper = Paper::where('paper_id', $this->ocrRecord->analysis_id)->first();
  89. }
  90. /**
  91. * 匹配OCR识别结果与系统试卷题目
  92. */
  93. private function matchQuestions(): void
  94. {
  95. if (!$this->ocrRecord || !$this->paper) return;
  96. // 获取系统试卷的题目(作为匹配基准)
  97. $paperQuestions = PaperQuestion::where('paper_id', $this->paper->paper_id)
  98. ->orderBy('question_number')
  99. ->get();
  100. // 获取OCR识别的题目
  101. $ocrQuestions = OCRQuestionResult::where('ocr_record_id', $this->ocrRecord->id)
  102. ->orderBy('question_number')
  103. ->get();
  104. // 如果没有OCR题目记录,尝试从原始数据恢复
  105. if ($ocrQuestions->isEmpty()) {
  106. $rawOcrData = \Illuminate\Support\Facades\DB::table('ocr_raw_data')
  107. ->where('ocr_record_id', $this->ocrRecord->id)
  108. ->value('raw_response');
  109. if ($rawOcrData) {
  110. try {
  111. $rawOcrData = json_decode($rawOcrData, true);
  112. $parser = new \App\Services\OCRDataParser();
  113. $matchedResults = $parser->matchWithSystemPaper($rawOcrData, $paperQuestions);
  114. foreach ($matchedResults as $qNum => $result) {
  115. OCRQuestionResult::create([
  116. 'ocr_record_id' => $this->ocrRecord->id,
  117. 'question_number' => $qNum,
  118. 'question_text' => '系统题目 ' . $qNum, // 占位
  119. 'student_answer' => $result['student_answer'],
  120. 'score_confidence' => $result['confidence'],
  121. 'score_value' => 0,
  122. ]);
  123. }
  124. // 重新获取
  125. $ocrQuestions = OCRQuestionResult::where('ocr_record_id', $this->ocrRecord->id)
  126. ->orderBy('question_number')
  127. ->get();
  128. \Log::info('从原始数据恢复了OCR题目记录', ['count' => $ocrQuestions->count()]);
  129. } catch (\Exception $e) {
  130. \Log::error('从原始数据恢复OCR记录失败: ' . $e->getMessage());
  131. }
  132. }
  133. }
  134. $this->matchedQuestions = [];
  135. // 以系统试卷的题目为基准进行匹配
  136. foreach ($paperQuestions as $paperQuestion) {
  137. // 查找对应题号的OCR识别结果(可能有多个,取第一个有效的)
  138. $ocrQuestion = $ocrQuestions
  139. ->where('question_number', $paperQuestion->question_number)
  140. ->first();
  141. if ($ocrQuestion) {
  142. $this->matchedQuestions[] = [
  143. 'question_number' => $paperQuestion->question_number,
  144. 'ocr_id' => $ocrQuestion->id,
  145. 'paper_id' => $paperQuestion->id,
  146. 'question_text' => $paperQuestion->question_text ?: '系统题目',
  147. 'knowledge_point' => $paperQuestion->knowledge_point,
  148. 'question_type' => $paperQuestion->question_type,
  149. 'student_answer' => $ocrQuestion->student_answer,
  150. 'correct_answer' => $paperQuestion->correct_answer,
  151. 'full_score' => $paperQuestion->score,
  152. 'is_correct' => $ocrQuestion->is_correct,
  153. 'score' => $ocrQuestion->ai_score,
  154. 'ocr_confidence' => $ocrQuestion->score_confidence ?? null,
  155. 'bbox' => $ocrQuestion->student_answer_bbox,
  156. ];
  157. // 更新OCR记录,关联到题库
  158. if (!$ocrQuestion->question_bank_id) {
  159. $ocrQuestion->update([
  160. 'question_bank_id' => $paperQuestion->question_id,
  161. 'kp_code' => $paperQuestion->knowledge_point,
  162. ]);
  163. }
  164. } else {
  165. // 没有找到对应的OCR结果,但仍然显示系统题目
  166. $this->matchedQuestions[] = [
  167. 'question_number' => $paperQuestion->question_number,
  168. 'ocr_id' => null,
  169. 'paper_id' => $paperQuestion->id,
  170. 'question_text' => $paperQuestion->question_text ?: '系统题目',
  171. 'knowledge_point' => $paperQuestion->knowledge_point,
  172. 'question_type' => $paperQuestion->question_type,
  173. 'student_answer' => null,
  174. 'correct_answer' => $paperQuestion->correct_answer,
  175. 'full_score' => $paperQuestion->score,
  176. 'is_correct' => null,
  177. 'score' => null,
  178. 'ocr_confidence' => null,
  179. 'note' => 'OCR未识别到此题'
  180. ];
  181. }
  182. }
  183. // 记录匹配统计
  184. \Log::info('OCR题目匹配完成', [
  185. 'ocr_record_id' => $this->ocrRecord->id,
  186. 'paper_id' => $this->paper->paper_id,
  187. 'system_questions' => $paperQuestions->count(),
  188. 'ocr_questions_total' => $ocrQuestions->count(),
  189. 'matched_questions' => count($this->matchedQuestions),
  190. 'ocr_question_numbers' => $ocrQuestions->pluck('question_number')->unique()->values()->toArray(),
  191. ]);
  192. }
  193. /**
  194. * 执行答题分析
  195. */
  196. public function analyzeAnswers(): void
  197. {
  198. $this->isAnalyzing = true;
  199. try {
  200. $this->analysisResults = [];
  201. $totalScore = 0;
  202. $correctCount = 0;
  203. foreach ($this->matchedQuestions as &$matched) {
  204. $analysis = $this->analyzeAnswer(
  205. $matched['student_answer'],
  206. $matched['correct_answer'],
  207. $matched['question_type'],
  208. $matched['full_score']
  209. );
  210. $matched['is_correct'] = $analysis['is_correct'];
  211. $matched['score'] = $analysis['score'];
  212. $matched['analysis_details'] = $analysis['details'];
  213. $totalScore += $analysis['score'];
  214. if ($analysis['is_correct']) {
  215. $correctCount++;
  216. }
  217. // 更新数据库
  218. OCRQuestionResult::where('id', $matched['ocr_id'])
  219. ->update([
  220. 'ai_score' => $analysis['score'],
  221. 'is_correct' => $analysis['is_correct'],
  222. ]);
  223. $this->analysisResults[] = $analysis;
  224. }
  225. unset($matched);
  226. // 更新OCR记录状态
  227. $this->ocrRecord->update([
  228. 'ai_analyzed_at' => now(),
  229. ]);
  230. // 计算统计信息
  231. $stats = [
  232. 'total_questions' => count($this->matchedQuestions),
  233. 'correct_count' => $correctCount,
  234. 'incorrect_count' => count($this->matchedQuestions) - $correctCount,
  235. 'total_score' => $totalScore,
  236. 'full_score' => $this->paper->total_score,
  237. 'accuracy_rate' => round(($correctCount / count($this->matchedQuestions)) * 100, 2),
  238. 'score_rate' => round(($totalScore / $this->paper->total_score) * 100, 2),
  239. ];
  240. // 可选:发送到Learning Analytics进行深度分析
  241. $this->sendToLearningAnalytics($stats);
  242. Notification::make()
  243. ->title('分析完成')
  244. ->body(sprintf(
  245. '共%d道题,正确%d道,得分%d分(满分%d分)',
  246. $stats['total_questions'],
  247. $stats['correct_count'],
  248. $stats['total_score'],
  249. $stats['full_score']
  250. ))
  251. ->success()
  252. ->send();
  253. } catch (\Exception $e) {
  254. \Log::error('试卷答题分析失败: ' . $e->getMessage());
  255. Notification::make()
  256. ->title('分析失败')
  257. ->body($e->getMessage())
  258. ->danger()
  259. ->send();
  260. } finally {
  261. $this->isAnalyzing = false;
  262. }
  263. }
  264. /**
  265. * 分析单个答案
  266. */
  267. private function analyzeAnswer(?string $studentAnswer, ?string $correctAnswer, string $questionType, float $fullScore): array
  268. {
  269. $studentAnswer = trim($studentAnswer ?? '');
  270. $correctAnswer = trim($correctAnswer ?? '');
  271. // 空答案处理
  272. if (empty($studentAnswer)) {
  273. return [
  274. 'is_correct' => false,
  275. 'score' => 0,
  276. 'details' => '未作答'
  277. ];
  278. }
  279. $isCorrect = false;
  280. $score = 0;
  281. $details = '';
  282. // 根据题型进行不同的分析
  283. switch ($questionType) {
  284. case '选择题':
  285. case 'choice':
  286. $result = $this->analyzeChoiceAnswer($studentAnswer, $correctAnswer);
  287. break;
  288. case '填空题':
  289. case 'fill':
  290. $result = $this->analyzeFillAnswer($studentAnswer, $correctAnswer);
  291. break;
  292. case '解答题':
  293. case 'answer':
  294. $result = $this->analyzeAnswerAnswer($studentAnswer, $correctAnswer, $fullScore);
  295. break;
  296. default:
  297. $result = $this->analyzeGeneralAnswer($studentAnswer, $correctAnswer, $fullScore);
  298. }
  299. return $result;
  300. }
  301. /**
  302. * 分析选择题答案
  303. */
  304. private function analyzeChoiceAnswer(string $studentAnswer, string $correctAnswer): array
  305. {
  306. $studentAnswer = $this->normalizeChoiceAnswer($studentAnswer);
  307. $correctAnswer = $this->normalizeChoiceAnswer($correctAnswer);
  308. $isCorrect = $studentAnswer === $correctAnswer;
  309. return [
  310. 'is_correct' => $isCorrect,
  311. 'score' => $isCorrect ? $fullScore : 0,
  312. 'details' => $isCorrect ? '正确' : '错误'
  313. ];
  314. }
  315. /**
  316. * 分析填空题答案
  317. */
  318. private function analyzeFillAnswer(string $studentAnswer, string $correctAnswer): array
  319. {
  320. // 精确匹配
  321. if (strcasecmp($studentAnswer, $correctAnswer) === 0) {
  322. return [
  323. 'is_correct' => true,
  324. 'score' => $fullScore,
  325. 'details' => '完全正确'
  326. ];
  327. }
  328. // 去除空格后匹配
  329. if (strcasecmp(str_replace(' ', '', $studentAnswer), str_replace(' ', '', $correctAnswer)) === 0) {
  330. return [
  331. 'is_correct' => true,
  332. 'score' => $fullScore * 0.9, // 扣10%
  333. 'details' => '基本正确(多空格)'
  334. ];
  335. }
  336. // 数值比较
  337. if (is_numeric($studentAnswer) && is_numeric($correctAnswer)) {
  338. if (abs(floatval($studentAnswer) - floatval($correctAnswer)) < 0.001) {
  339. return [
  340. 'is_correct' => true,
  341. 'score' => $fullScore,
  342. 'details' => '数值正确'
  343. ];
  344. }
  345. }
  346. return [
  347. 'is_correct' => false,
  348. 'score' => 0,
  349. 'details' => '错误'
  350. ];
  351. }
  352. /**
  353. * 分析解答题答案(简化版,实际需要更复杂的评分逻辑)
  354. */
  355. private function analyzeAnswerAnswer(string $studentAnswer, string $correctAnswer, float $fullScore): array
  356. {
  357. // 简化处理:解答题需要人工评分或更复杂的AI评分
  358. // 这里仅做简单的文本相似度比较
  359. $similar = similar_text($studentAnswer, $correctAnswer, $percent);
  360. if ($percent > 80) {
  361. return [
  362. 'is_correct' => true,
  363. 'score' => $fullScore,
  364. 'details' => sprintf('相似度%.1f%%,建议人工复核', $percent)
  365. ];
  366. } elseif ($percent > 50) {
  367. return [
  368. 'is_correct' => false,
  369. 'score' => $fullScore * 0.5,
  370. 'details' => sprintf('部分正确(相似度%.1f%%)', $percent)
  371. ];
  372. } else {
  373. return [
  374. 'is_correct' => false,
  375. 'score' => 0,
  376. 'details' => sprintf('相似度%.1f%%', $percent)
  377. ];
  378. }
  379. }
  380. /**
  381. * 通用答案分析
  382. */
  383. private function analyzeGeneralAnswer(string $studentAnswer, string $correctAnswer, float $fullScore): array
  384. {
  385. $isCorrect = strcasecmp($studentAnswer, $correctAnswer) === 0;
  386. return [
  387. 'is_correct' => $isCorrect,
  388. 'score' => $isCorrect ? $fullScore : 0,
  389. 'details' => $isCorrect ? '正确' : '错误'
  390. ];
  391. }
  392. /**
  393. * 标准化选择题答案
  394. */
  395. private function normalizeChoiceAnswer(string $answer): string
  396. {
  397. // 处理各种格式:A, B, C, D 或 a, b, c, d 或 ①, ②, ③, ④ 或 1, 2, 3, 4
  398. $map = [
  399. '①' => 'a', '②' => 'b', '③' => 'c', '④' => 'd',
  400. '1' => 'a', '2' => 'b', '3' => 'c', '4' => 'd',
  401. 'A' => 'a', 'B' => 'b', 'C' => 'c', 'D' => 'd',
  402. ];
  403. $answer = trim($answer);
  404. return $map[$answer] ?? strtolower($answer);
  405. }
  406. /**
  407. * 发送分析结果到Learning Analytics
  408. */
  409. private function sendToLearningAnalytics(array $stats): void
  410. {
  411. try {
  412. $client = new \GuzzleHttp\Client();
  413. $response = $client->post('http://localhost:5016/api/student/exam-analysis', [
  414. 'json' => [
  415. 'student_id' => $this->paper->student_id,
  416. 'paper_id' => $this->paper->paper_id,
  417. 'analysis_type' => 'ocr_matching',
  418. 'stats' => $stats,
  419. 'detailed_results' => $this->matchedQuestions,
  420. 'timestamp' => now()->toISOString(),
  421. ]
  422. ]);
  423. if ($response->getStatusCode() === 200) {
  424. \Log::info('分析结果已发送到Learning Analytics');
  425. }
  426. } catch (\Exception $e) {
  427. \Log::error('发送分析结果到Learning Analytics失败: ' . $e->getMessage());
  428. }
  429. }
  430. /**
  431. * 导出报告
  432. */
  433. public function exportReport(): void
  434. {
  435. // 生成简单的文本报告
  436. $report = $this->generateTextReport();
  437. $filename = "试卷分析报告_" . date('Y-m-d_H-i-s') . ".txt";
  438. $filepath = storage_path("reports/" . $filename);
  439. // 确保目录存在
  440. if (!is_dir(dirname($filepath))) {
  441. mkdir(dirname($filepath), 0755, true);
  442. }
  443. file_put_contents($filepath, $report);
  444. // 提供下载链接
  445. Notification::make()
  446. ->title('报告导出成功')
  447. ->body('报告已保存到:' . $filename)
  448. ->success()
  449. ->send();
  450. }
  451. /**
  452. * 生成文本报告
  453. */
  454. private function generateTextReport(): string
  455. {
  456. $stats = $this->getAnalysisStats();
  457. $report = "";
  458. // 报告头部
  459. $report .= "=====================================\n";
  460. $report .= "试卷分析报告\n";
  461. $report .= "=====================================\n\n";
  462. $report .= "试卷名称:" . $this->paper->paper_name . "\n";
  463. $report .= "学生姓名:" . ($this->studentInfo()['name'] ?? '未知') . "\n";
  464. $report .= "班级:" . ($this->studentInfo()['class'] ?? '未知') . "\n";
  465. $report .= "分析时间:" . date('Y-m-d H:i:s') . "\n\n";
  466. // 统计信息
  467. $report .= "【统计信息】\n";
  468. $report .= "-------------------------\n";
  469. $report .= "题目总数:" . $stats['total'] . "题\n";
  470. $report .= "正确题数:" . $stats['correct'] . "题\n";
  471. $report .= "错误题数:" . $stats['incorrect'] . "题\n";
  472. $report .= "正确率:" . $stats['accuracy'] . "%\n";
  473. $report .= "得分:" . $stats['score'] . "分\n";
  474. $report .= "满分:" . $stats['full_score'] . "分\n";
  475. $report .= "得分率:" . $stats['score_rate'] . "%\n\n";
  476. // 详细题目分析
  477. $report .= "【题目详情】\n";
  478. $report .= "-------------------------\n";
  479. foreach ($this->matchedQuestions as $index => $question) {
  480. $report .= "\n题目" . ($index + 1) . ":\n";
  481. $report .= " 知识点:" . ($question['knowledge_point'] ?? '未知') . "\n";
  482. $report .= " 学生答案:" . ($question['student_answer'] ?: '未作答') . "\n";
  483. $report .= " 正确答案:" . ($question['correct_answer'] ?? '未知') . "\n";
  484. $report .= " 得分:" . ($question['score'] ?? 0) . "分\n";
  485. $report .= " 状态:" . ($question['is_correct'] ? '正确' : '错误') . "\n";
  486. if (isset($question['analysis_details'])) {
  487. $report .= " 说明:" . $question['analysis_details'] . "\n";
  488. }
  489. }
  490. return $report;
  491. }
  492. /**
  493. * 获取分析统计信息
  494. */
  495. public function getAnalysisStats(): array
  496. {
  497. if (empty($this->analysisResults)) return [];
  498. $correct = 0;
  499. $total = count($this->matchedQuestions);
  500. $score = 0;
  501. foreach ($this->matchedQuestions as $matched) {
  502. if ($matched['is_correct']) $correct++;
  503. $score += $matched['score'];
  504. }
  505. return [
  506. 'total' => $total,
  507. 'correct' => $correct,
  508. 'incorrect' => $total - $correct,
  509. 'accuracy' => $total > 0 ? round(($correct / $total) * 100, 2) : 0,
  510. 'score' => $score,
  511. 'full_score' => $this->paper->total_score,
  512. 'score_rate' => $this->paper->total_score > 0 ? round(($score / $this->paper->total_score) * 100, 2) : 0,
  513. ];
  514. }
  515. /**
  516. * 重新匹配题目
  517. */
  518. public function rematchQuestions(): void
  519. {
  520. try {
  521. $rawOcrData = \Illuminate\Support\Facades\DB::table('ocr_raw_data')
  522. ->where('ocr_record_id', $this->ocrRecord->id)
  523. ->value('raw_response');
  524. if (!$rawOcrData) {
  525. Notification::make()->title('未找到原始OCR数据')->danger()->send();
  526. return;
  527. }
  528. $rawOcrData = json_decode($rawOcrData, true);
  529. $paperQuestions = PaperQuestion::where('paper_id', $this->paper->paper_id)
  530. ->orderBy('question_number')
  531. ->get();
  532. $ocrService = app(\App\Services\OCRService::class);
  533. $matchedResults = $ocrService->performEnhancedMatching($this->ocrRecord, $rawOcrData, $paperQuestions);
  534. // 更新现有记录
  535. foreach ($matchedResults as $result) {
  536. OCRQuestionResult::updateOrCreate(
  537. [
  538. 'ocr_record_id' => $this->ocrRecord->id,
  539. 'question_number' => $result['question_number'],
  540. ],
  541. [
  542. 'student_answer' => $result['student_answer'],
  543. 'score_confidence' => $result['confidence'],
  544. 'student_answer_bbox' => $result['student_answer_bbox'] ?? null,
  545. 'question_text' => '系统题目 ' . $result['question_number'], // 确保有值
  546. ]
  547. );
  548. }
  549. // 刷新页面数据
  550. $this->matchQuestions();
  551. Notification::make()->title('重新匹配完成')->success()->send();
  552. } catch (\Exception $e) {
  553. \Log::error('重新匹配失败: ' . $e->getMessage());
  554. Notification::make()->title('重新匹配失败')->body($e->getMessage())->danger()->send();
  555. }
  556. }
  557. /**
  558. * 提交AI分析
  559. */
  560. public function submitForAiAnalysis(): void
  561. {
  562. try {
  563. // 确保统计信息是最新的
  564. $stats = $this->getAnalysisStats();
  565. if (empty($stats)) {
  566. // 如果还没有分析过,先简单统计一下(或者强制先运行 analyzeAnswers)
  567. $this->analyzeAnswers();
  568. $stats = $this->getAnalysisStats();
  569. }
  570. $this->sendToLearningAnalytics($stats);
  571. Notification::make()->title('已提交AI分析请求')->success()->send();
  572. } catch (\Exception $e) {
  573. Notification::make()->title('提交失败')->body($e->getMessage())->danger()->send();
  574. }
  575. }
  576. /**
  577. * 判断是否已完成分析
  578. */
  579. public function hasAnalysis(): bool
  580. {
  581. return !empty($this->analysisResults) ||
  582. ($this->ocrRecord && $this->ocrRecord->ai_analyzed_at);
  583. }
  584. }