Selaa lähdekoodia

主要是ocr 2 question

yemeishu 1 kuukausi sitten
vanhempi
commit
438155ff0e

+ 181 - 1
app/Filament/Pages/OCRRecordView.php

@@ -27,6 +27,10 @@ class OCRRecordView extends Page
     // 新增:判卷相关
     public array $questionGrades = []; // 存储每道题的评分 [question_id => ['score' => x, 'is_correct' => true/false]]
 
+    public bool $isGenerating = false;
+    public ?string $generationTaskId = null;
+    public array $questionGenerationStatus = [];
+
     #[Computed]
     public function record(): ?OCRRecord
     {
@@ -63,6 +67,13 @@ class OCRRecordView extends Page
                         'is_correct' => $question->is_correct,
                     ];
                 }
+
+                // 初始化生成状态
+                $this->questionGenerationStatus[$question->id] = $question->generation_status ?? 'pending';
+                if ($question->generation_status === 'generating' && $question->generation_task_id) {
+                    $this->isGenerating = true;
+                    $this->generationTaskId = $question->generation_task_id;
+                }
             }
 
             // 检查是否已有AI分析结果
@@ -81,12 +92,181 @@ class OCRRecordView extends Page
             ->exists();
     }
 
+    /**
+     * 生成题库题目
+     */
+    public function generateQuestionBankQuestions(): void
+    {
+        $record = $this->record();
+        if (!$record) return;
+
+        $questionsToGenerate = [];
+        foreach ($record->questions as $question) {
+            // 只有未关联题库的题目才需要生成
+            if (!$question->question_bank_id) {
+                $questionsToGenerate[] = [
+                    'id' => $question->question_number, // 使用id字段匹配ocr_question_number
+                    'content' => $question->question_text,
+                    // 可以传递更多字段,如知识点等
+                    'student_answer' => $question->student_answer,
+                    'kp_code' => $question->kp_code,
+                ];
+            }
+        }
+
+        if (empty($questionsToGenerate)) {
+            Notification::make()
+                ->title('无需生成')
+                ->body('所有题目已关联题库')
+                ->success()
+                ->send();
+            return;
+        }
+
+        try {
+            $service = app(\App\Services\QuestionBankService::class);
+
+            // 使用异步API,让系统自动生成回调URL
+            $response = $service->generateQuestionsFromOcrAsync(
+                $questionsToGenerate,
+                $record->student->grade ?? '高一', // 假设有年级字段
+                '数学', // 默认科目
+                $record->id, // OCR记录ID,用于关联
+                null, // 让系统自动生成回调URL
+                'api.ocr.callback' // 回调路由名称
+            );
+
+            if ($response['status'] === 'processing' && isset($response['task_id'])) {
+                $this->isGenerating = true;
+                $this->generationTaskId = $response['task_id'];
+
+                // 更新数据库状态为生成中
+                foreach ($record->questions as $question) {
+                    if (!$question->question_bank_id) {
+                        $question->update([
+                            'generation_status' => 'generating',
+                            'generation_task_id' => $this->generationTaskId,
+                            'generation_error' => null
+                        ]);
+                        $this->questionGenerationStatus[$question->id] = 'generating';
+                    }
+                }
+
+                Notification::make()
+                    ->title('开始生成')
+                    ->body('已提交题库题目生成任务,系统将在后台处理并自动关联结果...')
+                    ->success()
+                    ->send();
+            } else {
+                throw new \Exception($response['message'] ?? '未知错误');
+            }
+        } catch (\Exception $e) {
+            Notification::make()
+                ->title('生成失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
+    }
+
+    /**
+     * 检查生成状态 (被轮询调用)
+     */
+    public function checkGenerationStatus(): void
+    {
+        if (!$this->isGenerating || !$this->generationTaskId) {
+            return;
+        }
+
+        try {
+            $service = app(\App\Services\QuestionBankService::class);
+            $status = $service->checkGenerationTaskStatus($this->generationTaskId);
+
+            if (($status['status'] ?? '') === 'completed') {
+                $this->isGenerating = false;
+                $results = $status['results'] ?? [];
+
+                // 更新题目关联
+                $record = $this->record();
+                foreach ($record->questions as $question) {
+                    // 在结果中查找对应题目(假设通过 question_number 匹配)
+                    $result = collect($results)->firstWhere('question_number', $question->question_number);
+                    
+                    if ($result && isset($result['question_id'])) {
+                        $question->update([
+                            'question_bank_id' => $result['question_id'],
+                            'generation_status' => 'completed',
+                            'generation_error' => null
+                        ]);
+                        $this->questionGenerationStatus[$question->id] = 'completed';
+                    } elseif ($question->generation_status === 'generating') {
+                        // 如果任务完成了但没找到这个题的结果,标记为失败
+                         $question->update([
+                            'generation_status' => 'failed',
+                            'generation_error' => '生成结果中未找到对应题目'
+                        ]);
+                        $this->questionGenerationStatus[$question->id] = 'failed';
+                    }
+                }
+
+                Notification::make()
+                    ->title('生成完成')
+                    ->body('题库题目已生成并关联')
+                    ->success()
+                    ->send();
+            } elseif (($status['status'] ?? '') === 'failed') {
+                $this->isGenerating = false;
+                $record = $this->record();
+                foreach ($record->questions as $question) {
+                    if ($question->generation_status === 'generating') {
+                        $question->update([
+                            'generation_status' => 'failed',
+                            'generation_error' => $status['message'] ?? '任务执行失败'
+                        ]);
+                        $this->questionGenerationStatus[$question->id] = 'failed';
+                    }
+                }
+                
+                Notification::make()
+                    ->title('生成失败')
+                    ->body($status['message'] ?? '任务执行失败')
+                    ->danger()
+                    ->send();
+            }
+        } catch (\Exception $e) {
+            \Log::error('检查生成状态失败: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * 检查是否可以提交分析
+     */
+    public function canSubmitAnalysis(): bool
+    {
+        $record = $this->record();
+        if (!$record) return false;
+
+        // 检查是否有题目未关联题库ID
+        // 排除那些可能不需要生成的(如果有的话),这里假设所有OCR题目都需要进题库
+        return !$record->questions()->whereNull('question_bank_id')->exists();
+    }
+
     /**
      * Submit all questions for AI analysis.
      * Updates manual answers in batch, then sends data to LearningAnalytics using unified interface.
      */
     public function submitForAnalysis(): void
     {
+        // 1. 检查是否所有题目都已关联题库
+        if (!$this->canSubmitAnalysis()) {
+            Notification::make()
+                ->title('请先生成题库题目')
+                ->body('分析前需要确保所有题目都已在题库中创建并关联')
+                ->warning()
+                ->send();
+            return;
+        }
+
         $record = $this->record();
         if (! $record) {
             Notification::make()
@@ -121,7 +301,7 @@ class OCRRecordView extends Page
                     : trim($question->student_answer ?? '');
 
                 $answers[] = [
-                    'question_bank_id' => 'ocr_q' . $question->question_number,
+                    'question_bank_id' => $question->question_bank_id, // 使用真实的题库ID
                     'question_text' => $question->question_text ?? '',
                     'student_answer' => $studentAnswer,
                     'is_correct' => null,  // 让AI判断

+ 4 - 0
app/Models/OCRQuestionResult.php

@@ -38,6 +38,10 @@ class OCRQuestionResult extends Model
         'ai_confidence',
         'ai_analysis_method',
         'ai_analyzed_at',
+        'question_bank_id',
+        'generation_status',
+        'generation_task_id',
+        'generation_error',
     ];
 
     protected $casts = [

+ 60 - 36
app/Services/MathFormulaProcessor.php

@@ -29,8 +29,9 @@ class MathFormulaProcessor
         
         $content = trim($content);
 
-        // 2. 规范化反斜杠
-        $content = preg_replace('/\\\\+([a-zA-Z])/', '\\\\$1', $content);
+        // 2. 规范化反斜杠 - 修复重复转义问题
+        // 暂时禁用修复功能,避免进一步污染
+        // $content = self::fixCorruptedFormulas($content);
 
         // 3. 修复常见 LaTeX 命令
         $commands = [
@@ -103,39 +104,40 @@ class MathFormulaProcessor
      */
     private static function cleanInsideDelimiters(string $content): string
     {
-        // 定义定界符模式
-        $patterns = [
-            '/\$\$([\s\S]*?)\$\$/', // $$...$$
-            '/\$([\s\S]*?)\$/',     // $...$
-            '/\\\\\(([\s\S]*?)\\\\\)/', // \(...\)
-            '/\\\\\[([\s\S]*?)\\\\\]/'  // \[...\]
-        ];
+        // 修复:使用更精确的正则表达式,避免模式冲突
+        // 先处理 $$...$$ 模式,然后处理单个 $...$ 模式(但确保不被$$包含)
 
-        foreach ($patterns as $pattern) {
-            $content = preg_replace_callback($pattern, function ($matches) {
-                // $matches[0] 是完整匹配 (如 $...$)
-                // $matches[1] 是内部内容
-                
-                // 清理内部的 HTML 标签
-                $cleanContent = strip_tags($matches[1]);
-                // 解码实体
-                $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
-                $cleanContent = trim($cleanContent);
-                
-                // 重建定界符 (保持原样)
-                // 注意:我们需要根据原来的定界符类型来重建
-                // 这里简单起见,我们直接用匹配到的完整字符串的定界符部分
-                // 但 preg_replace_callback 不容易直接获取定界符,所以我们硬编码
-                
-                if (str_starts_with($matches[0], '$$')) return '$$' . $cleanContent . '$$';
-                if (str_starts_with($matches[0], '$')) return '$' . $cleanContent . '$';
-                if (str_starts_with($matches[0], '\[')) return '\[' . $cleanContent . '\]';
-                if (str_starts_with($matches[0], '\(')) return '\(' . $cleanContent . '\)';
-                
-                // 默认回退 (不应该发生)
-                return $matches[0];
-            }, $content);
-        }
+        // 1. 处理 $$...$$ 显示公式
+        $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) {
+            $cleanContent = strip_tags($matches[1]);
+            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
+            $cleanContent = trim($cleanContent);
+            return '$$' . $cleanContent . '$$';
+        }, $content);
+
+        // 2. 处理 \(...\) 行内公式
+        $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) {
+            $cleanContent = strip_tags($matches[1]);
+            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
+            $cleanContent = trim($cleanContent);
+            return '\\(' . $cleanContent . '\\)';
+        }, $content);
+
+        // 3. 处理 \[...\] 显示公式
+        $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) {
+            $cleanContent = strip_tags($matches[1]);
+            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
+            $cleanContent = trim($cleanContent);
+            return '\\[' . $cleanContent . '\\]';
+        }, $content);
+
+        // 4. 最后处理 $...$ 行内公式(避免与$$冲突)
+        $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) {
+            $cleanContent = strip_tags($matches[1]);
+            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
+            $cleanContent = trim($cleanContent);
+            return '$' . $cleanContent . '$';
+        }, $content);
 
         return $content;
     }
@@ -282,10 +284,32 @@ class MathFormulaProcessor
     public static function processQuestionData(array $question): array
     {
         $fieldsToProcess = [
-            'stem', 'content', 'question_text', 'answer', 
-            'correct_answer', 'student_answer', 'explanation', 
+            'stem', 'content', 'question_text', 'answer',
+            'correct_answer', 'student_answer', 'explanation',
             'solution', 'question_content'
         ];
         return self::processArray($question, $fieldsToProcess);
     }
+
+    /**
+     * 修复被污染的数学公式(包含重复的转义字符)
+     */
+    private static function fixCorruptedFormulas(string $content): string
+    {
+        // 简化的修复策略,只处理明确的问题
+
+        // 1. 将超过2个连续的$符号减少为2个
+        $content = preg_replace('/\${3,}/', '$$', $content);
+
+        // 2. 修复$$B . - \frac{1}{2}$$ 这种格式,在选项前加空格
+        $content = preg_replace('/\$\$([A-Z])\s*\.\s*/', '$$ $1. ', $content);
+
+        // 3. 修复不完整的frac命令:\frac{1}{2} -> \frac{1}{2}
+        $content = preg_replace('/\\\\frac\\\\({[^}]+)([^}]*)\\\\/', '\\\\frac$1}{$2}', $content);
+
+        // 4. 移除孤立的反斜杠(在非LaTeX命令前的)
+        $content = preg_replace('/\\\\(?![a-zA-Z{])/', '', $content);
+
+        return $content;
+    }
 }

+ 248 - 0
app/Services/QuestionBankService.php

@@ -671,4 +671,252 @@ class QuestionBankService
             return false;
         }
     }
+
+    /**
+     * 根据OCR识别的题目生成完整题目并保存到题库(异步模拟版本)
+     *
+     * @param array $questions OCR识别的题目列表
+     * @param string $gradeLevel 年级
+     * @param string $subject 科目
+     * @param int $ocrRecordId OCR记录ID,用于关联
+     * @param string|null $callbackUrl 回调URL(可选,如果不提供则自动生成)
+     * @param string|null $callbackRouteName 回调路由名称(用于动态生成URL)
+     * @return array 任务ID和状态
+     */
+    public function generateQuestionsFromOcrAsync(
+        array $questions,
+        string $gradeLevel = '高一',
+        string $subject = '数学',
+        int $ocrRecordId = null,
+        string $callbackUrl = null,
+        string $callbackRouteName = 'api.ocr.callback'
+    ): array {
+        try {
+            // 如果没有提供回调URL,但提供了OCR记录ID,则动态生成回调URL
+            if (!$callbackUrl && $ocrRecordId) {
+                $callbackUrl = $this->generateCallbackUrl($callbackRouteName);
+                Log::info('动态生成回调URL', [
+                    'route_name' => $callbackRouteName,
+                    'generated_url' => $callbackUrl
+                ]);
+            }
+
+            // 生成唯一的任务ID
+            $taskId = 'ocr_' . $ocrRecordId . '_' . time() . '_' . substr(md5(uniqid()), 0, 8);
+
+            // 更新OCR记录状态为生成中
+            if ($ocrRecordId) {
+                \DB::table('ocr_question_results')
+                    ->where('ocr_record_id', $ocrRecordId)
+                    ->where('question_bank_id', null) // 只更新未关联的题目
+                    ->update([
+                        'generation_status' => 'generating',
+                        'generation_task_id' => $taskId,
+                        'generation_error' => null
+                    ]);
+            }
+
+            // 启动后台任务(使用Laravel的队列)
+            if ($ocrRecordId && $callbackUrl) {
+                // 使用Laravel队列异步处理
+                $this->dispatchOcrGenerationJob($ocrRecordId, $questions, $gradeLevel, $subject, $callbackUrl, $taskId);
+            } else {
+                // 如果没有回调URL,使用同步方式
+                $response = $this->generateQuestionsFromOcr($questions, $gradeLevel, $subject);
+                return $response;
+            }
+
+            Log::info('OCR题目生成任务已提交到队列', [
+                'task_id' => $taskId,
+                'ocr_record_id' => $ocrRecordId,
+                'questions_count' => count($questions),
+                'callback_url' => $callbackUrl
+            ]);
+
+            return [
+                'status' => 'processing',
+                'task_id' => $taskId,
+                'ocr_record_id' => $ocrRecordId,
+                'message' => '题目生成任务已启动,完成后将通过回调通知',
+                'estimated_time' => '约' . (count($questions) * 2) . '秒',
+                'callback_info' => [
+                    'will_callback' => !empty($callbackUrl),
+                    'callback_url' => $callbackUrl
+                ]
+            ];
+
+        } catch (\Exception $e) {
+            Log::error('OCR题目生成任务提交异常', [
+                'error' => $e->getMessage(),
+                'ocr_record_id' => $ocrRecordId
+            ]);
+
+            return [
+                'status' => 'error',
+                'message' => '任务提交失败: ' . $e->getMessage()
+            ];
+        }
+    }
+
+    /**
+     * 分发OCR生成任务到队列
+     */
+    private function dispatchOcrGenerationJob(
+        int $ocrRecordId,
+        array $questions,
+        string $gradeLevel,
+        string $subject,
+        string $callbackUrl,
+        string $taskId
+    ): void {
+        try {
+            // 转换题目数据格式
+            $formattedQuestions = [];
+            foreach ($questions as $q) {
+                $formattedQuestions[] = [
+                    'id' => $q['id'] ?? uniqid(),
+                    'content' => $q['content'] ?? ''
+                ];
+            }
+
+            // 直接调用QuestionBank API的异步端点,提供回调URL
+            $response = Http::timeout(60)
+                ->post($this->baseUrl . '/api/questions/generate-from-ocr', [
+                    'ocr_record_id' => $ocrRecordId,
+                    'questions' => $formattedQuestions,
+                    'grade_level' => $gradeLevel,
+                    'subject' => $subject,
+                    'callback_url' => $callbackUrl
+                ]);
+
+            if (!$response->successful()) {
+                Log::error('提交OCR题目生成任务失败', [
+                    'status' => $response->status(),
+                    'body' => $response->body(),
+                    'task_id' => $taskId
+                ]);
+
+                // 发送失败回调
+                $callbackData = [
+                    'task_id' => $taskId,
+                    'ocr_record_id' => $ocrRecordId,
+                    'status' => 'failed',
+                    'error' => 'API调用失败: ' . $response->status(),
+                    'timestamp' => now()->toISOString()
+                ];
+
+                Http::timeout(10)
+                    ->post($callbackUrl, $callbackData);
+                return;
+            }
+
+            $result = $response->json();
+            Log::info('OCR题目生成任务已提交到QuestionBank', [
+                'task_id' => $taskId,
+                'questionbank_task_id' => $result['task_id'] ?? 'unknown',
+                'status' => $result['status'] ?? 'unknown',
+                'callback_url' => $callbackUrl
+            ]);
+
+            // QuestionBank API会异步处理并通过回调通知,这里不需要立即触发回调
+            // 回调会在题目生成完成后由QuestionBank API主动发送
+
+        } catch (\Exception $e) {
+            Log::error('OCR生成任务处理失败', [
+                'task_id' => $taskId,
+                'ocr_record_id' => $ocrRecordId,
+                'error' => $e->getMessage()
+            ]);
+
+            // 发送异常回调
+            try {
+                $callbackData = [
+                    'task_id' => $taskId,
+                    'ocr_record_id' => $ocrRecordId,
+                    'status' => 'failed',
+                    'error' => $e->getMessage(),
+                    'timestamp' => now()->toISOString()
+                ];
+
+                Http::timeout(10)
+                    ->post($callbackUrl, $callbackData);
+            } catch (\Exception $callbackException) {
+                Log::error('发送异常回调失败', [
+                    'error' => $callbackException->getMessage()
+                ]);
+            }
+        }
+    }
+
+    /**
+     * 动态生成回调URL
+     *
+     * @param string $routeName 路由名称
+     * @return string 完整的回调URL
+     */
+    private function generateCallbackUrl(string $routeName): string
+    {
+        try {
+            // 获取当前请求的域名
+            $appUrl = config('app.url', 'http://localhost');
+
+            // 如果是在命令行环境中运行,使用配置的域名
+            if (app()->runningInConsole()) {
+                $domain = config('services.question_bank.callback_domain', $appUrl);
+            } else {
+                $domain = request()->getSchemeAndHttpHost();
+            }
+
+            // 确保domain不为null
+            $domain = $domain ?? $appUrl;
+
+            // 移除末尾的斜杠
+            $domain = rtrim($domain, '/');
+
+            // 生成完整的URL
+            $callbackUrl = $domain . route($routeName, [], false);
+
+            Log::info('生成回调URL', [
+                'route_name' => $routeName,
+                'domain' => $domain,
+                'app_url' => $appUrl,
+                'callback_url' => $callbackUrl
+            ]);
+
+            return $callbackUrl;
+        } catch (\Exception $e) {
+            // 如果路由生成失败,使用默认URL
+            Log::warning('路由生成失败,使用默认URL', [
+                'route_name' => $routeName,
+                'error' => $e->getMessage()
+            ]);
+
+            $fallbackUrl = config('app.url', 'http://localhost');
+            if ($routeName === 'api.ocr.callback') {
+                return $fallbackUrl . '/api/ocr-question-callback';
+            }
+
+            return $fallbackUrl;
+        }
+    }
+
+    /**
+     * 根据OCR识别的题目生成题库题目(同步版本,向后兼容)
+     *
+     * @param array $questions OCR题目数组 [['question_number' => 1, 'question_text' => '...']]
+     * @param string $gradeLevel 年级
+     * @param string $subject 科目
+     * @return array 生成结果
+     */
+    public function generateQuestionsFromOcr(array $questions, string $gradeLevel = '高一', string $subject = '数学'): array
+    {
+        return $this->generateQuestionsFromOcrAsync($questions, $gradeLevel, $subject);
+    }
+    /**
+     * 检查题目生成任务状态
+     */
+    public function checkGenerationTaskStatus(string $taskId): array
+    {
+        return $this->getTaskStatus($taskId) ?? ['status' => 'unknown'];
+    }
 }

+ 1 - 0
config/services.php

@@ -41,6 +41,7 @@ return [
 
     'question_bank' => [
         'base_url' => env('QUESTION_BANK_API_BASE', 'http://localhost:5015'),
+        'callback_domain' => env('QUESTION_BANK_CALLBACK_DOMAIN', null),
     ],
 
     'learning_analytics' => [

+ 36 - 0
database/migrations/2025_11_25_125117_add_question_bank_fields_to_ocr_question_results.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('ocr_question_results', function (Blueprint $table) {
+            $table->unsignedBigInteger('question_bank_id')->nullable()->after('ocr_record_id')->comment('题库题目ID');
+            $table->string('generation_status')->default('pending')->after('question_bank_id')->comment('题库生成状态: pending, generating, completed, failed');
+            $table->string('generation_task_id')->nullable()->after('generation_status')->comment('异步生成任务ID');
+            $table->text('generation_error')->nullable()->after('generation_task_id')->comment('生成失败错误信息');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('ocr_question_results', function (Blueprint $table) {
+            $table->dropColumn([
+                'question_bank_id',
+                'generation_status',
+                'generation_task_id',
+                'generation_error'
+            ]);
+        });
+    }
+};

+ 6 - 2
phpunit.xml

@@ -23,8 +23,12 @@
         <env name="BCRYPT_ROUNDS" value="4"/>
         <env name="BROADCAST_CONNECTION" value="null"/>
         <env name="CACHE_STORE" value="array"/>
-        <env name="DB_CONNECTION" value="sqlite"/>
-        <env name="DB_DATABASE" value=":memory:"/>
+        <env name="DB_CONNECTION" value="mysql"/>
+        <env name="DB_HOST" value="120.78.197.180"/>
+        <env name="DB_PORT" value="3306"/>
+        <env name="DB_DATABASE" value="math"/>
+        <env name="DB_USERNAME" value="root"/>
+        <env name="DB_PASSWORD" value="bamasoso902"/>
         <env name="MAIL_MAILER" value="array"/>
         <env name="QUEUE_CONNECTION" value="sync"/>
         <env name="SESSION_DRIVER" value="array"/>

+ 152 - 0
public/js/math-formula-processor.js

@@ -0,0 +1,152 @@
+/**
+ * 数学公式预处理器
+ * 用于标准化各种数学公式格式,避免渲染死循环
+ */
+
+window.MathFormulaProcessor = {
+
+    /**
+     * 预处理数学公式内容
+     * @param {string} content 原始内容
+     * @return {string} 处理后的内容
+     */
+    processContent: function(content) {
+        if (!content || typeof content !== 'string') {
+            return content;
+        }
+
+        // 1. 标准化数学公式分隔符
+        content = this.standardizeDelimiters(content);
+
+        // 2. 转义特殊字符
+        content = this.escapeSpecialChars(content);
+
+        // 3. 修复常见的LaTeX错误
+        content = this.fixLatexErrors(content);
+
+        return content;
+    },
+
+    /**
+     * 标准化数学公式分隔符
+     */
+    standardizeDelimiters: function(content) {
+        // 统一使用 $ 和 $$ 作为分隔符
+        content = content.replace(/\\\(\\s*\$/g, '$');
+        content = content.replace(/\$\\s*\\\)/g, '$');
+        content = content.replace(/\\\[\\s*\$/g, '$$');
+        content = content.replace(/\$\\s*\\\]/g, '$$');
+
+        return content;
+    },
+
+    /**
+     * 转义特殊字符,防止渲染冲突
+     */
+    escapeSpecialChars: function(content) {
+        // 检测是否已经在公式中
+        let inFormula = false;
+        let result = '';
+
+        for (let i = 0; i < content.length; i++) {
+            const char = content[i];
+            const nextChar = content[i + 1];
+
+            // 检测公式开始
+            if (char === '$' && nextChar === '$') {
+                inFormula = true;
+                result += char;
+                i++; // 跳过下一个$
+                continue;
+            }
+
+            if (char === '$') {
+                inFormula = !inFormula;
+                result += char;
+                continue;
+            }
+
+            // 在公式外转义某些字符
+            if (!inFormula) {
+                switch (char) {
+                    case '\\':
+                        // 只转义不是LaTeX命令的反斜杠
+                        if (this.isLatexCommand(content, i)) {
+                            result += char;
+                        } else {
+                            result += '\\\\';
+                        }
+                        break;
+                    case '{':
+                    case '}':
+                        result += char;
+                        break;
+                    default:
+                        result += char;
+                }
+            } else {
+                result += char;
+            }
+        }
+
+        return result;
+    },
+
+    /**
+     * 检测当前位置是否在LaTeX命令中
+     */
+    isLatexCommand: function(content, index) {
+        // 简单检测:查看反斜杠后面是否跟着字母
+        let i = index + 1;
+        while (i < content.length && /\s/.test(content[i])) {
+            i++;
+        }
+
+        return i < content.length && /[a-zA-Z]/.test(content[i]);
+    },
+
+    /**
+     * 修复常见的LaTeX错误
+     */
+    fixLatexErrors: function(content) {
+        // 1. 修复不匹配的公式分隔符
+        let formulaCount = 0;
+        let inDisplayFormula = false;
+
+        for (let i = 0; i < content.length; i++) {
+            if (content[i] === '$' && content[i + 1] === '$') {
+                if (inDisplayFormula) {
+                    formulaCount++;
+                    inDisplayFormula = false;
+                } else {
+                    inDisplayFormula = true;
+                }
+                i++; // 跳过下一个$
+            } else if (content[i] === '$') {
+                formulaCount++;
+            }
+        }
+
+        // 如果公式数量是奇数,补全分隔符
+        if (formulaCount % 2 === 1) {
+            content += '$';
+        }
+
+        // 2. 修复空的数学公式
+        content = content.replace(/\$\s*\$/g, ' $\\text{空}$ ');
+        content = content.replace(/\$\$\s*\$\$/g, '$$\\text{空}$$$');
+
+        return content;
+    },
+
+    /**
+     * 清理重复的数学公式标记
+     */
+    cleanupDuplicateMarkers: function(content) {
+        // 移除连续的相同分隔符
+        content = content.replace(/\$\$\$\$\$/g, '$$');
+        content = content.replace(/\$\$\$/g, '$');
+
+        return content;
+    }
+};

+ 166 - 44
resources/views/components/math-render.blade.php

@@ -9,13 +9,20 @@
 <script src="/js/katex.min.js?v={{ time() }}"></script>
 <!-- 引入 auto-render 扩展 (本地) -->
 <script src="/js/auto-render.min.js?v={{ time() }}"></script>
+<!-- 引入数学公式预处理器 -->
+<script src="/js/math-formula-processor.js?v={{ time() }}"></script>
 
 <script>
 (function() {
     'use strict';
 
     console.log('Math Render Script Loaded (Local Auto-Render)');
-    
+
+    // 防止无限递归的全局开关
+    window.MATH_RENDER_LOCK = false;
+    window.MATH_RENDER_COUNT = 0;
+    const MAX_RENDER_COUNT = 50; // 最大渲染次数,防止死循环
+
     // 配置项
     const renderOptions = {
         delimiters: [
@@ -29,35 +36,101 @@
     };
 
     function renderAllMath() {
+        // 防止无限递归
+        if (window.MATH_RENDER_LOCK) {
+            console.warn('Math render already in progress, skipping...');
+            return;
+        }
+
+        if (window.MATH_RENDER_COUNT >= MAX_RENDER_COUNT) {
+            console.warn('Max render count reached, stopping to prevent infinite loop');
+            return;
+        }
+
+        window.MATH_RENDER_LOCK = true;
+        window.MATH_RENDER_COUNT++;
+
         if (typeof renderMathInElement === 'undefined') {
             console.warn('auto-render extension not loaded yet');
+            window.MATH_RENDER_LOCK = false;
             return;
         }
-        
+
         if (typeof window.katex === 'undefined') {
              console.warn('katex core not loaded yet');
+             window.MATH_RENDER_LOCK = false;
              return;
         }
 
-        console.log('Rendering math using auto-render...');
-        
-        // 1. 优先渲染 .math-render 元素 (通常是经过后端处理的)
-        const elements = document.querySelectorAll('.math-render');
-        elements.forEach(elem => {
-            try {
-                renderMathInElement(elem, renderOptions);
-                elem.dataset.rendered = 'true';
-            } catch (e) {
-                console.error('Auto-render failed for element:', elem, e);
-            }
-        });
-        
-        // 2. 尝试渲染整个 body,以捕获未被包裹的公式
-        // 使用较低的优先级或特定的容器如果可能
+        console.log(`Rendering math using auto-render... (attempt #${window.MATH_RENDER_COUNT})`);
+
         try {
-            renderMathInElement(document.body, renderOptions);
-        } catch (e) {
-            console.warn('Global auto-render warning:', e);
+            // 1. 优先渲染 .math-render 元素 (通常是经过后端处理的)
+            const elements = document.querySelectorAll('.math-render');
+            let renderedCount = 0;
+
+            elements.forEach(elem => {
+                // 避免重复渲染
+                if (elem.dataset.rendered === 'true') {
+                    return;
+                }
+                try {
+                    // 预处理内容
+                    let originalContent = elem.textContent || elem.innerHTML || '';
+                    if (originalContent.includes('$') || originalContent.includes('\\(') || originalContent.includes('\\[')) {
+
+                        // 使用预处理器标准化内容
+                        const processedContent = window.MathFormulaProcessor ?
+                            window.MathFormulaProcessor.processContent(originalContent) : originalContent;
+
+                        // 避免重复处理
+                        if (processedContent !== originalContent) {
+                            if (elem.innerHTML !== processedContent) {
+                                elem.innerHTML = processedContent;
+                            }
+                        }
+
+                        renderMathInElement(elem, renderOptions);
+                        elem.dataset.rendered = 'true';
+                        renderedCount++;
+                    }
+                } catch (e) {
+                    console.error('Auto-render failed for element:', elem, e);
+                }
+            });
+
+            // 2. 只渲染特定的容器,避免全局渲染导致死循环
+            const containers = [
+                '.question-content',
+                '.math-content',
+                '.ocr-content',
+                '.question-text'
+            ];
+
+            containers.forEach(selector => {
+                const container = document.querySelector(selector);
+                if (container && container.dataset.mathRendered !== 'true') {
+                    try {
+                        // 检查是否包含数学公式
+                        const content = container.textContent || '';
+                        if (content.includes('$') || content.includes('\\(') || content.includes('\\[')) {
+                            renderMathInElement(container, renderOptions);
+                            container.dataset.mathRendered = 'true';
+                            renderedCount++;
+                        }
+                    } catch (e) {
+                        console.warn('Auto-render failed for container:', selector, e);
+                    }
+                }
+            });
+
+            console.log(`Rendered ${renderedCount} math elements (total attempts: ${window.MATH_RENDER_COUNT})`);
+
+        } finally {
+            // 延迟解锁,避免立即再次触发
+            setTimeout(() => {
+                window.MATH_RENDER_LOCK = false;
+            }, 50);
         }
     }
 
@@ -85,53 +158,102 @@
     function setupObservers() {
         const observer = new MutationObserver((mutations) => {
             let shouldRender = false;
+            let hasMathContent = false;
+
             mutations.forEach((mutation) => {
                 if (mutation.addedNodes.length > 0) {
-                    shouldRender = true;
+                    mutation.addedNodes.forEach((node) => {
+                        if (node.nodeType === 1) { // Element node
+                            // 检查是否包含数学公式标识符
+                            const textContent = node.textContent || '';
+                            if (textContent.includes('$') ||
+                                textContent.includes('\\(') ||
+                                textContent.includes('\\[') ||
+                                node.querySelector('.math-render') ||
+                                node.querySelector('.question-content') ||
+                                node.querySelector('.math-content') ||
+                                node.querySelector('.ocr-content') ||
+                                node.querySelector('.question-text')) {
+                                hasMathContent = true;
+                                shouldRender = true;
+                            }
+                        }
+                    });
                 }
             });
-            
-            if (shouldRender) {
+
+            if (shouldRender && hasMathContent) {
                 if (window.mathRenderTimeout) clearTimeout(window.mathRenderTimeout);
                 window.mathRenderTimeout = setTimeout(() => {
-                    console.log('DOM mutation detected');
+                    console.log('DOM mutation detected with math content');
                     renderAllMath();
-                }, 100);
+                }, 200); // 增加延迟,避免频繁触发
             }
         });
 
-        observer.observe(document.body, {
-            childList: true,
-            subtree: true
-        });
-    }
+        // 只观察特定容器,减少全局观察的性能影响
+        const containers = [
+            '.question-content',
+            '.math-content',
+            '.ocr-content',
+            '.question-text',
+            '.math-render'
+        ];
 
-    // Livewire 兼容性
-    document.addEventListener('livewire:initialized', () => {
-        setTimeout(renderAllMath, 50);
-        
-        if (typeof Livewire !== 'undefined' && Livewire.hook) {
-            Livewire.hook('morph.updated', ({ el, component }) => {
-                renderAllMath();
-            });
-            Livewire.hook('commit', ({ component, commit, respond, succeed, fail }) => {
-                succeed(({ snapshot, effect }) => {
-                    setTimeout(renderAllMath, 50);
+        containers.forEach(selector => {
+            const elements = document.querySelectorAll(selector);
+            elements.forEach(element => {
+                observer.observe(element, {
+                    childList: true,
+                    subtree: true
                 });
             });
+        });
+
+        // 限制全局观察的范围,只在必要时启用
+        // observer.observe(document.body, {
+        //     childList: true,
+        //     subtree: true
+        // });
+    }
+
+    // Livewire 兼容性 - 添加防抖机制
+    function debouncedRender() {
+        if (window.mathRenderDebounceTimeout) {
+            clearTimeout(window.mathRenderDebounceTimeout);
         }
+        window.mathRenderDebounceTimeout = setTimeout(() => {
+            renderAllMath();
+        }, 150);
+    }
+
+    document.addEventListener('livewire:initialized', () => {
+        setTimeout(debouncedRender, 50);
     });
 
+    if (typeof Livewire !== 'undefined' && Livewire.hook) {
+        Livewire.hook('morph.updated', ({ el, component }) => {
+            debouncedRender();
+        });
+        Livewire.hook('commit', ({ component, commit, respond, succeed, fail }) => {
+            succeed(({ snapshot, effect }) => {
+                debouncedRender();
+            });
+        });
+    }
+
     document.addEventListener('livewire:navigated', () => {
-        setTimeout(renderAllMath, 50);
+        // 重置计数器,允许新页面重新开始渲染
+        window.MATH_RENDER_COUNT = 0;
+        setTimeout(debouncedRender, 100);
     });
 
     document.addEventListener('alpine:init', () => {
-        setTimeout(renderAllMath, 50);
+        setTimeout(debouncedRender, 50);
     });
 
     document.addEventListener('math:render', () => {
-        renderAllMath();
+        debouncedRender();
     });
 
 })();

+ 77 - 1
resources/views/filament/pages/ocr-record-view-new.blade.php

@@ -1,6 +1,6 @@
 <x-filament-panels::page>
 
-<div class="space-y-6">
+<div class="space-y-6" @if($isGenerating) wire:poll.5s="checkGenerationStatus" @endif>
     @php
         $record = $this->record();
     @endphp
@@ -243,6 +243,44 @@
                     @endif
                 </div>
 
+                {{-- 题库生成提示 --}}
+                @if(!$this->canSubmitAnalysis())
+                    <div class="alert alert-warning shadow-sm mb-6">
+                        <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
+                        <div class="flex-1">
+                            <h3 class="font-bold">需生成题库题目</h3>
+                            <div class="text-xs">当前有题目尚未关联题库,必须先生成题库题目才能进行 AI 分析。</div>
+                            @if($isGenerating)
+                                <div class="mt-2 flex items-center gap-2 text-xs">
+                                    <span class="loading loading-spinner loading-xs"></span>
+                                    正在生成中,请稍候...
+                                </div>
+                            @endif
+                        </div>
+                        <div class="flex-none">
+                            <button 
+                                wire:click="generateQuestionBankQuestions" 
+                                class="btn btn-sm btn-ghost border-current" 
+                                @if($isGenerating) disabled @endif
+                                wire:loading.attr="disabled"
+                                wire:target="generateQuestionBankQuestions"
+                            >
+                                <span wire:loading.remove wire:target="generateQuestionBankQuestions">
+                                    @if($isGenerating)
+                                        生成中...
+                                    @else
+                                        生成题库题目
+                                    @endif
+                                </span>
+                                <span wire:loading wire:target="generateQuestionBankQuestions">
+                                    <span class="loading loading-spinner loading-xs"></span>
+                                    提交中...
+                                </span>
+                            </button>
+                        </div>
+                    </div>
+                @endif
+
                 @if($record->questions && count($record->questions) > 0)
                     <div class="overflow-x-auto">
                         <table class="table table-zebra table-compact">
@@ -251,6 +289,7 @@
                                     <th class="w-12">#</th>
                                     <th>题目内容</th>
                                     <th>学生答案</th>
+                                    <th>题库关联</th>
                                     <th class="w-32">手动校准</th>
                                     <th class="w-32">判卷</th>
                                     <th class="w-24">AI分析</th>
@@ -288,6 +327,34 @@
                                                 @endif
                                             </div>
                                         </td>
+                                        <td>
+                                            @if($question->question_bank_id)
+                                                <div class="badge badge-success badge-sm gap-1" title="题库ID: {{ $question->question_bank_id }}">
+                                                    <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
+                                                    已关联
+                                                </div>
+                                                <div class="text-[10px] text-base-content/40 font-mono mt-1">ID:{{ $question->question_bank_id }}</div>
+                                            @else
+                                                @php
+                                                    $genStatus = $questionGenerationStatus[$question->id] ?? ($question->generation_status ?? 'pending');
+                                                @endphp
+                                                @if($genStatus === 'generating')
+                                                    <div class="badge badge-info badge-sm gap-1">
+                                                        <span class="loading loading-spinner loading-xs w-3 h-3"></span>
+                                                        生成中
+                                                    </div>
+                                                @elseif($genStatus === 'failed')
+                                                     <div class="badge badge-error badge-sm gap-1 tooltip" data-tip="{{ $question->generation_error }}">
+                                                        <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
+                                                        失败
+                                                    </div>
+                                                @else
+                                                    <div class="badge badge-warning badge-sm gap-1">
+                                                        未关联
+                                                    </div>
+                                                @endif
+                                            @endif
+                                        </td>
                                         <td>
                                             <input
                                                 type="text"
@@ -425,11 +492,20 @@
                                         <p class="text-sm text-base-content/70 mt-1">
                                             将使用手动校准的答案(如有),否则使用 OCR 识别结果进行智能分析
                                         </p>
+                                        @if(!$this->canSubmitAnalysis())
+                                            <div class="text-xs text-warning mt-2">
+                                                <svg class="w-3 h-3 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L5.268 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
+                                                </svg>
+                                                需要先完成题库题目生成
+                                            </div>
+                                        @endif
                                     </div>
                                     <button
                                         wire:click="submitForAnalysis"
                                         class="btn btn-primary btn-lg"
                                         wire:loading.attr="disabled"
+                                        @if(!$this->canSubmitAnalysis()) disabled @endif
                                     >
                                         <span wire:loading.remove>
                                             <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 255 - 0
routes/api.php

@@ -55,6 +55,182 @@ Route::post('/questions/callback', function () {
     }
 })->name('api.questions.callback');
 
+// 接收OCR题目生成回调
+Route::post('/ocr-question-callback', function () {
+    try {
+        $data = request()->all();
+        Log::info('Received OCR question generation callback', $data);
+
+        // 验证必要的回调数据
+        if (!isset($data['task_id']) || !isset($data['status']) || !isset($data['ocr_record_id'])) {
+            Log::error('OCR callback missing required fields', $data);
+            return response()->json([
+                'success' => false,
+                'error' => 'Missing required fields: task_id, status, ocr_record_id'
+            ], 400);
+        }
+
+        $taskId = $data['task_id'];
+        $ocrRecordId = $data['ocr_record_id'];
+        $status = $data['status'];
+
+        // 将回调结果存储到缓存中,供前端查询(保留30秒)
+        $cacheKey = "ocr_callback_{$ocrRecordId}_{$taskId}";
+        cache([$cacheKey => $data], now()->addSeconds(30));
+
+        Log::info("OCR callback cached with key: {$cacheKey}", [
+            'ocr_record_id' => $ocrRecordId,
+            'task_id' => $taskId,
+            'status' => $status,
+            'total_generated' => $data['result']['total_generated'] ?? 0,
+            'total_saved' => $data['result']['total_saved'] ?? 0
+        ]);
+
+        // 处理题目关联逻辑
+        if ($status === 'completed') {
+            $updatedCount = 0;
+            // 从result中提取question_mappings(QuestionBank API将它放在result字段中)
+            $mappings = $data['result']['question_mappings'] ?? $data['question_mappings'] ?? [];
+
+            Log::info("Processing OCR question associations", [
+                'ocr_record_id' => $ocrRecordId,
+                'task_id' => $taskId,
+                'mappings_count' => count($mappings)
+            ]);
+
+            // 更新ocr_question_results表中的关联关系
+            foreach ($mappings as $mapping) {
+                try {
+                    $ocrQuestionNumber = $mapping['ocr_question_number'] ?? null;
+                    $questionBankId = $mapping['question_bank_id'] ?? null;
+                    $questionCode = $mapping['question_code'] ?? null;
+
+                    if ($ocrQuestionNumber && $questionBankId) {
+                        // 查找对应的OCR题目结果并更新
+                        $updated = DB::table('ocr_question_results')
+                            ->where('ocr_record_id', $ocrRecordId)
+                            ->where('question_number', $ocrQuestionNumber)
+                            ->update([
+                                'question_bank_id' => $questionBankId,
+                                'generation_status' => 'completed',
+                                'generation_task_id' => $taskId,
+                                'generation_error' => null,
+                            ]);
+
+                        if ($updated) {
+                            $updatedCount++;
+                            Log::info("Updated OCR question association", [
+                                'ocr_record_id' => $ocrRecordId,
+                                'question_number' => $ocrQuestionNumber,
+                                'question_bank_id' => $questionBankId,
+                                'question_code' => $questionCode
+                            ]);
+                        } else {
+                            Log::warning("No OCR question result found for association", [
+                                'ocr_record_id' => $ocrRecordId,
+                                'question_number' => $ocrQuestionNumber
+                            ]);
+                        }
+                    }
+                } catch (\Exception $e) {
+                    Log::error("Failed to update OCR question association", [
+                        'mapping' => $mapping,
+                        'error' => $e->getMessage()
+                    ]);
+                }
+            }
+
+            Log::info("OCR question association completed", [
+                'ocr_record_id' => $ocrRecordId,
+                'task_id' => $taskId,
+                'total_mappings' => count($mappings),
+                'updated_count' => $updatedCount
+            ]);
+
+            // 更新OCR记录的整体状态为已完成
+            try {
+                DB::table('ocr_records')
+                    ->where('id', $ocrRecordId)
+                    ->update([
+                        'status' => 'completed',
+                        'processed_at' => now(),
+                        'updated_at' => now()
+                    ]);
+
+                Log::info("Updated OCR record status to completed", [
+                    'ocr_record_id' => $ocrRecordId,
+                    'task_id' => $taskId
+                ]);
+            } catch (\Exception $e) {
+                Log::error("Failed to update OCR record status", [
+                    'ocr_record_id' => $ocrRecordId,
+                    'error' => $e->getMessage()
+                ]);
+            }
+
+        } elseif ($status === 'failed') {
+            // 更新所有相关的OCR题目结果为失败状态
+            try {
+                $updated = DB::table('ocr_question_results')
+                    ->where('ocr_record_id', $ocrRecordId)
+                    ->where('generation_status', 'pending') // 只更新待处理的
+                    ->update([
+                        'generation_status' => 'failed',
+                        'generation_task_id' => $taskId,
+                        'generation_error' => $data['error'] ?? 'Unknown error',
+                    ]);
+
+                Log::info("Updated OCR questions to failed status", [
+                    'ocr_record_id' => $ocrRecordId,
+                    'task_id' => $taskId,
+                    'updated_count' => $updated,
+                    'error' => $data['error'] ?? 'Unknown error'
+                ]);
+
+                // 更新OCR记录的状态为失败
+                DB::table('ocr_records')
+                    ->where('id', $ocrRecordId)
+                    ->update([
+                        'status' => 'failed',
+                        'error_message' => $data['error'] ?? 'Question generation failed',
+                        'updated_at' => now()
+                    ]);
+
+                Log::info("Updated OCR record status to failed", [
+                    'ocr_record_id' => $ocrRecordId,
+                    'task_id' => $taskId,
+                    'error' => $data['error'] ?? 'Unknown error'
+                ]);
+            } catch (\Exception $e) {
+                Log::error("Failed to update OCR questions to failed status", [
+                    'ocr_record_id' => $ocrRecordId,
+                    'error' => $e->getMessage()
+                ]);
+            }
+        }
+
+        return response()->json([
+            'success' => true,
+            'message' => 'OCR callback received and processed',
+            'data' => [
+                'task_id' => $taskId,
+                'ocr_record_id' => $ocrRecordId,
+                'status' => $status,
+                'cache_key' => $cacheKey,
+                'associations_processed' => $status === 'completed' ? count($data['question_mappings'] ?? []) : 0
+            ]
+        ]);
+
+    } catch (\Exception $e) {
+        Log::error('OCR callback processing failed: ' . $e->getMessage());
+        Log::error('Exception details: ' . $e->getTraceAsString());
+        return response()->json([
+            'success' => false,
+            'error' => 'Callback processing failed: ' . $e->getMessage()
+        ], 500);
+    }
+})->name('api.ocr.callback');
+
 // 获取题目生成回调结果
 Route::get('/questions/callback/{taskId}', function (string $taskId) {
     $callbackData = session('question_gen_callback_' . $taskId);
@@ -66,6 +242,27 @@ Route::get('/questions/callback/{taskId}', function (string $taskId) {
     return response()->json(['status' => 'pending'], 202);
 })->name('api.questions.callback.get');
 
+// 获取OCR题目生成回调结果
+Route::get('/ocr-question-callback/{ocrRecordId}/{taskId}', function (int $ocrRecordId, string $taskId) {
+    $cacheKey = "ocr_callback_{$ocrRecordId}_{$taskId}";
+    $callbackData = cache($cacheKey);
+
+    if ($callbackData) {
+        // 清除已读取的回调数据
+        cache()->forget($cacheKey);
+        return response()->json([
+            'success' => true,
+            'data' => $callbackData
+        ]);
+    }
+
+    return response()->json([
+        'success' => false,
+        'status' => 'pending',
+        'message' => 'OCR callback not received yet'
+    ], 202);
+})->name('api.ocr.callback.get');
+
 // 题目相关 API
 Route::get('/questions', function (QuestionServiceApi $service) {
     try {
@@ -221,3 +418,61 @@ Route::get('/mathrecsys/test', function () {
         'timestamp' => now()->toISOString()
     ]);
 })->name('api.mathrecsys.test');
+
+
+// 测试OCR题目生成API调用
+Route::post('/test-ocr-generation', function () {
+    try {
+        $service = new \App\Services\QuestionBankService();
+
+        // 模拟前端传递的OCR题目数据
+        $questions = [
+            [
+                'id' => 1,
+                'content' => '计算:2+3-4'
+            ],
+            [
+                'id' => 2,
+                'content' => '解方程:x+5=10'
+            ]
+        ];
+
+        Log::info('开始测试OCR题目生成', [
+            'questions_count' => count($questions),
+            'ocr_record_id' => 12
+        ]);
+
+        // 使用异步API,系统自动生成回调URL
+        $response = $service->generateQuestionsFromOcrAsync(
+            $questions,
+            '高一',
+            '数学',
+            12, // OCR记录ID
+            null, // 让系统自动生成回调URL
+            'api.ocr.callback' // 回调路由名称
+        );
+
+        Log::info('OCR题目生成响应', [
+            'response' => $response,
+            'status' => $response['status'] ?? 'unknown',
+            'task_id' => $response['task_id'] ?? 'N/A'
+        ]);
+
+        return response()->json([
+            'success' => true,
+            'message' => 'OCR题目生成测试完成',
+            'data' => $response
+        ]);
+
+    } catch (\Exception $e) {
+        Log::error('测试OCR题目生成失败', [
+            'error' => $e->getMessage(),
+            'trace' => $e->getTraceAsString()
+        ]);
+
+        return response()->json([
+            'success' => false,
+            'error' => $e->getMessage()
+        ], 500);
+    }
+})->name('api.test.ocr.generation');

+ 175 - 0
tests/Feature/OcrQuestionAnalysisTest.php

@@ -0,0 +1,175 @@
+<?php
+
+namespace Tests\Feature;
+
+use Tests\TestCase;
+use Illuminate\Support\Facades\Http;
+use App\Models\OCRRecord;
+use App\Models\OCRQuestionResult;
+
+class OcrQuestionAnalysisTest extends TestCase
+{
+    /**
+     * 不使用数据库事务,使用真实数据库
+     */
+    protected $connectionsToTransact = [];
+
+    /**
+     * 测试OCR题目分析API
+     *
+     * 从数据库获取已OCR识别的题目,发送到题库API进行分析
+     */
+    public function test_ocr_question_analysis()
+    {
+        // 强制使用MySQL连接
+        config(['database.default' => 'mysql']);
+
+        echo "\n" . str_repeat("=", 80) . "\n";
+        echo "🧪 OCR题目分析测试\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        // 1. 从数据库获取OCR题目
+        $recordId = 12;
+        echo "📚 从数据库读取OCR记录 #{$recordId} 的题目...\n";
+
+        $ocrQuestions = OCRQuestionResult::where('ocr_record_id', $recordId)
+            ->orderBy('question_number')
+            ->get();
+
+        if ($ocrQuestions->isEmpty()) {
+            echo "❌ 未找到OCR记录 {$recordId} 的题目数据\n";
+            $this->fail("No OCR questions found for record {$recordId}");
+            return;
+        }
+
+        echo "✅ 获取到 {$ocrQuestions->count()} 道题目\n\n";
+
+        // 2. 构建请求数据
+        $questions = [];
+        foreach ($ocrQuestions as $oq) {
+            // 使用校准后的答案,如果没有则使用OCR识别的答案
+            $studentAnswer = !empty($oq->manual_answer) ? $oq->manual_answer : $oq->student_answer;
+
+            $questions[] = [
+                'question_number' => $oq->question_number,
+                'question_text' => $oq->question_text ?? '',
+                'student_answer' => $studentAnswer ?? ''
+            ];
+        }
+
+        // 3. 调用题库API
+        echo "📤 发送请求到题库API: http://localhost:5015/api/questions/analyze-ocr\n";
+        echo "题目数量: " . count($questions) . "\n\n";
+
+        echo "题目列表:\n";
+        foreach ($questions as $i => $q) {
+            $preview = mb_substr($q['question_text'], 0, 50);
+            echo "  " . ($i + 1) . ". {$preview}...\n";
+            echo "     学生答案: {$q['student_answer']}\n";
+        }
+        echo "\n";
+
+        echo "🤖 调用AI分析中,请稍候...\n\n";
+
+        $response = Http::timeout(120)->post('http://localhost:5015/api/questions/analyze-ocr', [
+            'questions' => $questions,
+            'grade_level' => '高一',
+            'subject' => '数学'
+        ]);
+
+        // 4. 检查响应
+        if (!$response->successful()) {
+            echo "❌ API调用失败\n";
+            echo "状态码: {$response->status()}\n";
+            echo "响应: " . $response->body() . "\n";
+            $this->fail("API call failed with status " . $response->status());
+            return;
+        }
+
+        $result = $response->json();
+
+        if ($result['status'] !== 'success') {
+            echo "❌ 分析失败: " . ($result['message'] ?? 'Unknown error') . "\n";
+            $this->fail("Analysis failed: " . ($result['message'] ?? 'Unknown error'));
+            return;
+        }
+
+        // 5. 显示分析结果
+        echo str_repeat("=", 80) . "\n";
+        echo "📊 分析结果\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        echo "✅ 分析成功,使用模型: " . ($result['model_used'] ?? 'unknown') . "\n\n";
+
+        $analyzedQuestions = $result['questions'] ?? [];
+
+        foreach ($ocrQuestions as $index => $original) {
+            if (!isset($analyzedQuestions[$index])) {
+                continue;
+            }
+
+            $analyzed = $analyzedQuestions[$index];
+
+            echo str_repeat("=", 60) . "\n";
+            echo "题目 " . ($index + 1) . "\n";
+            echo str_repeat("=", 60) . "\n\n";
+
+            // 原始题目
+            echo "📝 原始题目:\n";
+            $questionText = $original->question_text ?? '';
+            $preview = mb_substr($questionText, 0, 100);
+            echo "   {$preview}...\n\n";
+
+            // 学生答案vs标准答案
+            $studentAnswer = !empty($original->manual_answer) ? $original->manual_answer : $original->student_answer;
+            echo "👤 学生答案: {$studentAnswer}\n";
+            echo "✓  标准答案: " . ($analyzed['answer'] ?? 'N/A') . "\n";
+
+            $isCorrect = $analyzed['is_correct'] ?? false;
+            echo "   判断结果: " . ($isCorrect ? '✓ 正确' : '✗ 错误') . "\n\n";
+
+            // 知识点匹配
+            echo "🎯 知识点匹配:\n";
+            echo "   主知识点: " . ($analyzed['kp_code'] ?? 'N/A') . "\n";
+            echo "   全部知识点: " . implode(', ', $analyzed['kp_codes'] ?? []) . "\n";
+            echo "   匹配度: " . ($analyzed['kp_similarity'] ?? 'N/A') . "\n\n";
+
+            // 题目属性
+            echo "📊 题目属性:\n";
+            echo "   题型: " . ($analyzed['question_type'] ?? 'N/A') . "\n";
+            echo "   难度: " . ($analyzed['difficulty'] ?? 'N/A') . "\n\n";
+
+            // 解析
+            echo "💡 解析:\n";
+            $solution = $analyzed['solution'] ?? 'N/A';
+            $solutionPreview = mb_substr($solution, 0, 150);
+            echo "   {$solutionPreview}" . (mb_strlen($solution) > 150 ? '...' : '') . "\n\n";
+
+            // 错误说明
+            if (!$isCorrect && !empty($analyzed['explanation'])) {
+                echo "⚠️  错误说明:\n";
+                $explanation = $analyzed['explanation'];
+                $explanationPreview = mb_substr($explanation, 0, 150);
+                echo "   {$explanationPreview}" . (mb_strlen($explanation) > 150 ? '...' : '') . "\n\n";
+            }
+
+            // 题库状态
+            echo "💾 题库状态:\n";
+            echo "   题目ID: " . ($analyzed['question_bank_id'] ?? 'N/A') . "\n";
+            echo "   已保存: " . ($analyzed['saved_to_db'] ? '是' : '否(已存在)') . "\n\n";
+        }
+
+        echo str_repeat("=", 80) . "\n";
+        echo "✅ 测试完成\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        // 断言:确保所有题目都得到了分析
+        $this->assertEquals(count($questions), count($analyzedQuestions), "分析的题目数量应该与输入相同");
+
+        // 断言:每道题都有标准答案
+        foreach ($analyzedQuestions as $q) {
+            $this->assertNotEmpty($q['answer'], "题目 {$q['question_number']} 应该有标准答案");
+            $this->assertNotEmpty($q['kp_code'], "题目 {$q['question_number']} 应该有知识点");
+        }
+    }
+}

+ 151 - 0
tests/Feature/QuestionBankOcrGenerationTest.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace Tests\Feature;
+
+use Tests\TestCase;
+use App\Services\QuestionBankService;
+use Illuminate\Support\Facades\DB;
+
+class QuestionBankOcrGenerationTest extends TestCase
+{
+    /**
+     * 不使用数据库事务,使用真实数据库
+     */
+    protected $connectionsToTransact = [];
+
+    /**
+     * 测试从MySQL OCR数据生成题库
+     * 
+     * 此测试使用MySQL中已OCR识别的题目,避免重复调用第三方OCR接口
+     */
+    public function test_generate_questions_from_mysql_ocr_data()
+    {
+        // 强制使用MySQL连接
+        config(['database.default' => 'mysql']);
+
+        echo "\n" . str_repeat("=", 80) . "\n";
+        echo "🧪 OCR题目生成题库测试 (使用MySQL已有数据)\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        // 1. 从MySQL读取OCR识别的题目
+        $recordId = 12; // 使用已有的OCR记录
+        echo "📚 从MySQL读取OCR记录 #{$recordId} 的题目...\n";
+
+        $ocrQuestions = DB::connection('mysql')
+            ->table('ocr_question_results')
+            ->where('ocr_record_id', $recordId)
+            ->orderBy('question_number')
+            ->get();
+
+        if ($ocrQuestions->isEmpty()) {
+            echo "❌ 未找到OCR记录 {$recordId} 的题目数据\n";
+            $this->markTestSkipped("No OCR data found for record {$recordId}");
+            return;
+        }
+
+        echo "✅ 获取到 {$ocrQuestions->count()} 道题目\n\n";
+
+        // 2. 构建请求数据(只需要题目文本,不需要学生答案)
+        $questions = [];
+        foreach ($ocrQuestions as $oq) {
+            $questions[] = [
+                'question_number' => $oq->question_number,
+                'question_text' => $oq->question_text ?? ''
+            ];
+
+            echo "  题目 {$oq->question_number}: " . mb_substr($oq->question_text ?? '', 0, 50) . "...\n";
+        }
+        echo "\n";
+
+        // 3. 调用QuestionBankService生成题目
+        echo "🤖 调用题库服务生成题目...\n";
+        echo "API地址: " . config('services.question_bank.base_url', 'http://localhost:5015') . "\n\n";
+
+        $service = app(QuestionBankService::class);
+        $result = $service->generateQuestionsFromOcr($questions, '高一', '数学');
+
+        // 4. 验证结果
+        echo str_repeat("=", 80) . "\n";
+        echo "📊 分析结果\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        // 输出完整响应用于调试
+        echo "API返回数据:\n";
+        echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n\n";
+
+        $this->assertIsArray($result, "返回结果应该是数组");
+        $this->assertEquals('success', $result['status'] ?? '', "生成应该成功");
+
+        if ($result['status'] !== 'success') {
+            echo "❌ 生成失败: " . ($result['message'] ?? 'Unknown error') . "\n";
+            $this->fail("OCR题目生成失败: " . ($result['message'] ?? 'Unknown error'));
+            return;
+        }
+
+        echo "✅ 生成成功\n";
+        echo "使用模型: " . ($result['model_used'] ?? 'unknown') . "\n";
+        echo "生成数量: " . ($result['generated_count'] ?? 0) . "/" . ($result['total_count'] ?? 0) . "\n\n";
+
+        $generatedQuestions = $result['questions'] ?? [];
+        $this->assertNotEmpty($generatedQuestions, "应该返回生成的题目");
+
+        // 5. 验证每道生成的题目
+        foreach ($generatedQuestions as $index => $generated) {
+            echo str_repeat("-", 60) . "\n";
+            echo "题目 " . ($index + 1) . "\n";
+            echo str_repeat("-", 60) . "\n";
+
+            // 验证必要字段
+            $this->assertArrayHasKey('question_id', $generated, "题目应该有题库ID");
+            $this->assertArrayHasKey('question_type', $generated, "题目应该有题型");
+            $this->assertArrayHasKey('kp_code', $generated, "题目应该有知识点");
+            $this->assertArrayHasKey('saved', $generated, "题目应该有保存状态");
+
+            // 显示生成结果
+            echo "📝 题目ID: " . ($generated['question_id'] ?? 'N/A') . "\n";
+            echo "📊 题型: " . ($generated['question_type'] ?? 'N/A') . "\n";
+            echo "🎯 知识点: " . ($generated['kp_code'] ?? 'N/A') . "\n";
+            echo "   难度: " . ($generated['difficulty'] ?? 'N/A') . "\n";
+            echo "💾 已保存: " . ($generated['saved'] ? '是' : '否') . "\n\n";
+
+            // 验证已保存
+            $this->assertTrue($generated['saved'] ?? false, "题目应该已保存到题库");
+        }
+
+        // 6. 验证题目已保存到QuestionBankService数据库
+        echo str_repeat("=", 80) . "\n";
+        echo "🔍 验证题目已保存到题库\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        $savedCount = 0;
+        foreach ($generatedQuestions as $generated) {
+            if ($generated['saved'] ?? false) {
+                $savedCount++;
+            }
+        }
+
+        echo "✅ 成功保存 {$savedCount}/{$result['generated_count']} 道题目到题库\n";
+        $this->assertEquals($result['generated_count'], $savedCount, "所有生成的题目都应该已保存");
+
+        echo str_repeat("=", 80) . "\n";
+        echo "✅ 测试完成\n";
+        echo str_repeat("=", 80) . "\n\n";
+
+        // 最终断言
+        $this->assertTrue(true, "OCR题目生成题库测试通过");
+    }
+
+    /**
+     * 测试QuestionBankService健康检查
+     */
+    public function test_question_bank_service_health()
+    {
+        echo "\n🏥 检查题库服务健康状态...\n";
+
+        $service = app(QuestionBankService::class);
+        $isHealthy = $service->checkHealth();
+
+        $this->assertTrue($isHealthy, "题库服务应该健康");
+        echo "✅ 题库服务运行正常\n\n";
+    }
+}