Bläddra i källkod

教材相关 api

yemeishu 6 dagar sedan
förälder
incheckning
6d8e3c423b
48 ändrade filer med 5427 tillägg och 576 borttagningar
  1. 11 0
      app/Filament/AdminPanelProvider.php
  2. 576 169
      app/Filament/Pages/MarkdownImportWorkbench.php
  3. 3 0
      app/Filament/Pages/PromptManagement.php
  4. 96 0
      app/Filament/Pages/QuestionDetail.php
  5. 6 3
      app/Filament/Pages/QuestionManagement.php
  6. 10 2
      app/Filament/Pages/QuestionReviewWorkbench.php
  7. 13 2
      app/Filament/Resources/MarkdownImportResource.php
  8. 44 0
      app/Filament/Resources/PaperPartResource/Pages/ViewPaperPart.php
  9. 5 1
      app/Filament/Resources/PaperPartResource/RelationManagers/PreQuestionCandidatesRelationManager.php
  10. 42 4
      app/Filament/Resources/SourcePaperResource.php
  11. 68 0
      app/Filament/Resources/SourcePaperResource/Actions/GenerateQuestionsBulkAction.php
  12. 434 0
      app/Http/Controllers/Api/ExamAnswerAnalysisController.php
  13. 2 2
      app/Http/Controllers/ImportStreamController.php
  14. 72 12
      app/Jobs/ProcessMarkdownCandidateBatch.php
  15. 23 0
      app/Jobs/ProcessMarkdownSplit.php
  16. 75 0
      app/Jobs/PromoteSourcePapersJob.php
  17. 13 4
      app/Models/MarkdownImport.php
  18. 2 0
      app/Models/PreQuestionCandidate.php
  19. 6 1
      app/Models/SourcePaper.php
  20. 3 12
      app/Models/Textbook.php
  21. 2 2
      app/Services/AiKnowledgeService.php
  22. 1051 0
      app/Services/ApiDocumentation.php
  23. 27 18
      app/Services/AsyncMarkdownSplitter.php
  24. 94 1
      app/Services/ExamAnalysisService.php
  25. 635 0
      app/Services/ExamAnswerAnalysisService.php
  26. 366 0
      app/Services/ImportInferenceService.php
  27. 29 0
      app/Services/MarkdownQuestionParser.php
  28. 15 0
      app/Services/PaperPartExtractorService.php
  29. 18 14
      app/Services/PdfStorageService.php
  30. 166 4
      app/Services/PromptService.php
  31. 547 0
      app/Services/QuestionCandidateToQuestionService.php
  32. 31 32
      app/Services/QuestionExtractorService.php
  33. 1 0
      app/Services/QuestionGenerationService.php
  34. 19 4
      app/Services/QuestionImportService.php
  35. 3 3
      app/Services/QuestionLocalService.php
  36. 35 4
      app/Services/QuestionPromptService.php
  37. 150 4
      app/Services/SourcePaperExtractorService.php
  38. 42 1
      app/Services/StudentAnswerAnalysisService.php
  39. 12 0
      check_student_ids.php
  40. 10 5
      config/ai.php
  41. 1 1
      database/factories/QuestionFactory.php
  42. 462 250
      resources/views/filament/pages/markdown-import-workbench.blade.php
  43. 3 0
      resources/views/filament/pages/prompt-management.blade.php
  44. 3 5
      resources/views/filament/pages/question-candidate-workbench.blade.php
  45. 79 8
      resources/views/filament/pages/question-detail.blade.php
  46. 21 7
      resources/views/filament/pages/question-management-simple.blade.php
  47. 1 1
      resources/views/filament/resources/markdown-import-resource/pages/list-markdown-imports.blade.php
  48. 100 0
      routes/api.php

+ 11 - 0
app/Filament/AdminPanelProvider.php

@@ -28,6 +28,7 @@ class AdminPanelProvider extends PanelProvider
                 \App\Filament\Resources\TextbookSeriesResource::class,
                 \App\Filament\Resources\TextbookResource::class,
                 \App\Filament\Resources\TextbookCatalogResource::class,
+                \App\Filament\Resources\SourcePaperResource::class,
             ])
             ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
             ->pages([
@@ -38,6 +39,16 @@ class AdminPanelProvider extends PanelProvider
             ->widgets([
                 \Filament\Widgets\AccountWidget::class,
             ])
+            ->navigationGroups([
+                '教材管理',
+                '卷子导入流程',
+                '知识图谱管理',
+                '题库管理',
+                '卷子生成管理',
+                '学生管理',
+                'API 管理',
+                '其他',
+            ])
             ->middleware([
                 \Illuminate\Cookie\Middleware\EncryptCookies::class,
                 \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,

+ 576 - 169
app/Filament/Pages/MarkdownImportWorkbench.php

@@ -6,8 +6,13 @@ use App\Models\MarkdownImport;
 use App\Models\SourcePaper;
 use App\Models\Textbook;
 use App\Models\TextbookCatalog;
+use App\Services\ImportInferenceService;
+use App\Models\TextbookSeries;
 use Filament\Pages\Page;
 use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Filament\Notifications\Notification;
+use Illuminate\Support\Facades\Log;
 
 class MarkdownImportWorkbench extends Page
 {
@@ -18,11 +23,18 @@ class MarkdownImportWorkbench extends Page
 
     protected string $view = 'filament.pages.markdown-import-workbench';
 
+    protected ?ImportInferenceService $inferenceService = null;
+
+    protected function inferenceService(): ImportInferenceService
+    {
+        return $this->inferenceService ??= app(ImportInferenceService::class);
+    }
+
     public ?int $importId = null;
     public ?int $selectedPaperId = null;
     public array $selectedIds = [];
     public string $search = '';
-    public string $groupBy = 'bundle';
+    public string $groupBy = 'paper';
     public bool $dense = false;
     public bool $filenameValid = true;
     public array $filenameParsed = [];
@@ -38,12 +50,14 @@ class MarkdownImportWorkbench extends Page
         'source_year' => null,
         'textbook_id' => null,
         'textbook_series' => null,
+        'textbook_series_id' => null,
         'source_name' => null,
         'source_page' => null,
+        'subject' => '数学', // 默认学科
         'tags' => '',
         'bundle_key' => null,
         'expected_count' => null,
-        'catalog_node_id' => null,
+        'catalog_node_ids' => [],
     ];
 
     public array $batch = [
@@ -60,9 +74,11 @@ class MarkdownImportWorkbench extends Page
         'tags' => '',
         'bundle_key' => null,
         'expected_count' => null,
-        'catalog_node_id' => null,
+        'catalog_node_ids' => [],
     ];
 
+    private array $catalogNodeCache = [];
+
     public function mount(): void
     {
         $this->importId = request()->integer('import_id');
@@ -138,27 +154,170 @@ class MarkdownImportWorkbench extends Page
             return;
         }
 
-        $meta = $paper->meta ?? [];
+        Log::info('MarkdownImportWorkbench select paper', [
+            'import_id' => $this->importId,
+            'paper_id' => $paperId,
+            'paper_title' => $paper->title,
+            'selected_ids' => $this->selectedIds,
+        ]);
+
+        $parsed = $this->parseImportFilename();
         $this->selectedPaperId = $paperId;
+        $meta = $paper->meta ?? [];
+
+        // 核心修正:直接基于物理 series_id 初始化,如果没有则尝试从文件名或标题解析
+        $finalSeriesId = $paper->series_id;
+        $finalSeriesName = null;
+
+        if (!$finalSeriesId) {
+            // 兜底1:从文件名解析
+            if (!empty($parsed['series'])) {
+                $formalSeries = $this->inferenceService()->resolveSeries($parsed['series']);
+                $finalSeriesId = $formalSeries?->id;
+                $finalSeriesName = $formalSeries ? $formalSeries->name : $parsed['series'];
+            }
+            
+            // 兜底2:从标题模糊推断 (应对数据库数据未对齐的情况)
+            if (!$finalSeriesId) {
+                $title = $paper->full_title ?: $paper->title;
+                $formalSeries = $this->inferenceService()->resolveSeries($title);
+                $finalSeriesId = $formalSeries?->id;
+                $finalSeriesName = $formalSeries?->name;
+            }
+        }
+
+        if ($finalSeriesId && !$finalSeriesName) {
+            $finalSeriesName = TextbookSeries::find($finalSeriesId)?->name;
+        }
+
+        $resolvedTextbook = null;
+        if (!$paper->textbook_id && $finalSeriesId) {
+            $resolvedTextbook = $this->inferenceService()->findBestTextbook([
+                'series_id' => $finalSeriesId,
+                'grade' => $paper->grade ?: ($parsed['grade'] ?? null),
+                'term' => $paper->term ?: ($parsed['term'] ?? null),
+            ]);
+        }
+
+        $initialCatalogIds = (array) (Arr::get($meta, 'catalog_node_ids') ?: (Arr::get($meta, 'catalog_node_id') ? [Arr::get($meta, 'catalog_node_id')] : []));
+        $filteredCatalogIds = $this->filterCatalogNodeIdsForTextbook($paper->textbook_id ?: ($resolvedTextbook?->id ?? null), $initialCatalogIds);
+        if ($initialCatalogIds !== $filteredCatalogIds && !empty($initialCatalogIds)) {
+            Log::warning('MarkdownImportWorkbench catalog nodes not in textbook', [
+                'paper_id' => $paper->id,
+                'paper_title' => $paper->title,
+                'textbook_id' => $paper->textbook_id,
+                'original_catalog_node_ids' => $initialCatalogIds,
+                'filtered_catalog_node_ids' => $filteredCatalogIds,
+            ]);
+        }
+
         $this->form = [
             'title' => $paper->title,
             'edition' => $paper->edition,
-            'grade' => $paper->grade,
-            'term' => $paper->term,
+            'grade' => $paper->grade ?: ($parsed['grade'] ?? null),
+            'term' => $paper->term ?: ($parsed['term'] ?? null),
             'chapter' => $paper->chapter,
             'source_type' => $paper->source_type,
             'source_year' => $paper->source_year,
-            'textbook_id' => $paper->textbook_id,
-            'textbook_series' => $paper->textbook_series,
-            'source_name' => Arr::get($meta, 'source_name'),
+            'textbook_id' => $paper->textbook_id ?: ($resolvedTextbook?->id ?? null),
+            'textbook_series' => $finalSeriesName,
+            'textbook_series_id' => $finalSeriesId,
+            'source_name' => Arr::get($meta, 'source_name') ?: ($parsed['name'] ?? null),
             'source_page' => Arr::get($meta, 'source_page'),
             'tags' => implode(',', Arr::get($meta, 'tags', [])),
             'bundle_key' => Arr::get($meta, 'bundle_key'),
             'expected_count' => Arr::get($meta, 'expected_count'),
-            'catalog_node_id' => Arr::get($meta, 'catalog_node_id'),
+            'catalog_node_ids' => $filteredCatalogIds,
         ];
     }
 
+    public function updatedSelectedIds(): void
+    {
+        if (empty($this->selectedIds) || $this->selectedPaperId === null) {
+            return;
+        }
+
+        if ($this->isBatchEmpty()) {
+            $this->seedBatchFromCurrent();
+        }
+    }
+
+    public function updated($name, $value): void
+    {
+        if (!$this->selectedPaperId) {
+            return;
+        }
+    }
+
+    /**
+     * 教材属性联动:当系列、年级、学期改变时,自动匹配最合适的教材
+     */
+    public function updatedFormGrade(): void { $this->reInferTextbook(); }
+    public function updatedFormTerm(): void { $this->reInferTextbook(); }
+    public function updatedFormTextbookSeriesId(): void 
+    { 
+        // 核心联动:系列 ID 变动,清空教材并重推
+        $this->form['textbook_id'] = null;
+        $this->form['catalog_node_ids'] = [];
+        
+        // 同时同步一下显示名称 (虽然逻辑以 ID 为准,但保留名称用于前端显示或保存)
+        $series = TextbookSeries::find($this->form['textbook_series_id']);
+        $this->form['textbook_series'] = $series?->name;
+        
+        $this->reInferTextbook(); 
+    }
+
+    protected function reInferTextbook(): void
+    {
+        if (!empty($this->form['textbook_id'])) {
+            return;
+        }
+
+        $best = $this->inferenceService()->findBestTextbook($this->form);
+        if ($best) {
+            // 无论 ID 是否改变,都强制执行一次属性校准,确保“一环扣一环”
+            $this->syncTextbookAttributes($best);
+        }
+    }
+
+    protected function syncTextbookAttributes(Textbook $textbook): void
+    {
+        $this->form['textbook_id'] = $textbook->id;
+        $this->form['grade'] = (string)$textbook->grade;
+        $this->form['term'] = $this->semesterToTerm($textbook->semester);
+        $this->form['textbook_series_id'] = $textbook->series_id;
+        
+        $series = $textbook->getRelation('series') ?: $textbook->series()->first();
+        $seriesName = $textbook->track ?: ($series?->name ?? null);
+        if ($seriesName) {
+            $this->form['textbook_series'] = $seriesName;
+        }
+        
+        $this->savePaper();
+    }
+
+    public function updatedFormTextbookId($value): void
+    {
+        if (!$this->selectedPaperId || !$value) {
+            return;
+        }
+
+        // 权威源:从数据库获取该教材的所有官方属性
+        $textbook = Textbook::query()->with('series')->find($value);
+        if ($textbook) {
+            $this->syncTextbookAttributes($textbook);
+        }
+    }
+
+    protected function semesterToTerm(?int $semester): ?string
+    {
+        return match ($semester) {
+            1 => '上册',
+            2 => '下册',
+            default => null,
+        };
+    }
+
     public function seedBatchFromCurrent(): void
     {
         $this->batch = [
@@ -169,86 +328,115 @@ class MarkdownImportWorkbench extends Page
             'source_type' => $this->form['source_type'] ?? null,
             'source_year' => $this->form['source_year'] ?? null,
             'textbook_id' => $this->form['textbook_id'] ?? null,
+            'textbook_series_id' => $this->form['textbook_series_id'] ?? null,
             'textbook_series' => $this->form['textbook_series'] ?? null,
             'source_name' => $this->form['source_name'] ?? null,
             'source_page' => $this->form['source_page'] ?? null,
             'tags' => $this->form['tags'] ?? '',
             'bundle_key' => $this->form['bundle_key'] ?? null,
             'expected_count' => $this->form['expected_count'] ?? null,
-            'catalog_node_id' => $this->form['catalog_node_id'] ?? null,
+            'catalog_node_ids' => $this->form['catalog_node_ids'] ?? [],
         ];
     }
 
-    public function savePaper(): void
+    public function savePaper(bool $silent = false): void
     {
         $paper = $this->selectedPaper();
         if (!$paper) {
+            Log::warning('MarkdownImportWorkbench save failed: no paper selected', [
+                'import_id' => $this->importId,
+                'selected_paper_id' => $this->selectedPaperId,
+            ]);
             return;
         }
 
+        $selectedIds = array_values(array_filter(array_unique(array_map('intval', $this->selectedIds))));
+
         $meta = $paper->meta ?? [];
         $meta['source_name'] = $this->form['source_name'] ?? null;
         $meta['source_page'] = $this->form['source_page'] ?? null;
         $meta['tags'] = $this->explodeTags($this->form['tags'] ?? '');
         $meta['bundle_key'] = $this->form['bundle_key'] ?? null;
         $meta['expected_count'] = $this->form['expected_count'] ?? null;
-        $meta['catalog_node_id'] = $this->form['catalog_node_id'] ?? null;
+        
+        // 关键修正:确保目录 ID 是干净的整型数组,解决保存失效
+        $catalogNodeIds = $this->normalizeCatalogNodeIds($this->form['catalog_node_ids'] ?? []);
+        $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? null, $catalogNodeIds);
+        $meta['catalog_node_ids'] = $catalogNodeIds;
+        
+        // 同时保留旧字段供向后兼容
+        $meta['catalog_node_id'] = !empty($meta['catalog_node_ids']) ? $meta['catalog_node_ids'][0] : null;
+
+        // 核心同步:保存时根据 series_id 确保名称同步
+        if (!empty($this->form['textbook_series_id'])) {
+            $series = TextbookSeries::find($this->form['textbook_series_id']);
+            if ($series) {
+                $this->form['textbook_series'] = $series->name;
+            }
+        }
 
-        $paper->update([
-            'title' => $this->form['title'] ?? null,
-            'edition' => $this->form['edition'] ?? null,
-            'grade' => $this->form['grade'] ?? null,
-            'term' => $this->form['term'] ?? null,
-            'chapter' => $this->form['chapter'] ?? null,
-            'source_type' => $this->form['source_type'] ?? null,
-            'source_year' => $this->form['source_year'] ?? null,
-            'textbook_id' => $this->form['textbook_id'] ?? null,
-            'textbook_series' => $this->form['textbook_series'] ?? null,
-            'meta' => $meta,
+        $fields = [
+            'title', 'edition', 'grade', 'term', 'chapter', 'source_type', 
+            'source_year', 'textbook_id'
+        ];
+        
+        $updates = [];
+        foreach ($fields as $field) {
+            $value = $this->form[$field] ?? null;
+            $updates[$field] = ($value === '' ? null : $value);
+        }
+        $updates['series_id'] = $this->form['textbook_series_id'] ?? null;
+        $updates['meta'] = $meta;
+
+        Log::info('MarkdownImportWorkbench saving paper', [
+            'import_id' => $this->importId,
+            'paper_id' => $paper->id,
+            'paper_title' => $paper->title,
+            'catalog_node_ids' => $catalogNodeIds,
+            'textbook_id' => $updates['textbook_id'] ?? null,
+            'series_id' => $updates['series_id'] ?? null,
+        ]);
+
+        $paper->update($updates);
+
+        Log::info('MarkdownImportWorkbench saved paper', [
+            'import_id' => $this->importId,
+            'paper_id' => $paper->id,
+            'meta_catalog_node_ids' => $meta['catalog_node_ids'] ?? [],
         ]);
+
+        if (count($selectedIds) > 1) {
+            $otherIds = array_values(array_diff($selectedIds, [$paper->id]));
+            if (!empty($otherIds)) {
+                $this->seedBatchFromCurrent();
+                $this->applyBatchToIds($otherIds);
+
+                Log::info('MarkdownImportWorkbench batch saved papers', [
+                    'import_id' => $this->importId,
+                    'paper_ids' => $otherIds,
+                    'catalog_node_ids' => $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids'] ?? []),
+                ]);
+            }
+        }
+
+        $this->selectedIds = [];
+
+        if (!$silent) {
+            Notification::make()
+                ->title('保存成功')
+                ->success()
+                ->send();
+        }
     }
 
+
     public function applyBatch(): void
     {
         if (empty($this->selectedIds)) {
             return;
         }
 
-        $updates = array_filter([
-            'edition' => $this->batch['edition'] ?? null,
-            'grade' => $this->batch['grade'] ?? null,
-            'term' => $this->batch['term'] ?? null,
-            'chapter' => $this->batch['chapter'] ?? null,
-            'source_type' => $this->batch['source_type'] ?? null,
-            'source_year' => $this->batch['source_year'] ?? null,
-            'textbook_id' => $this->batch['textbook_id'] ?? null,
-            'textbook_series' => $this->batch['textbook_series'] ?? null,
-        ], fn ($value) => $value !== null && $value !== '');
-
-        foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) {
-            $meta = $paper->meta ?? [];
-
-            if (!empty($this->batch['source_name'])) {
-                $meta['source_name'] = $this->batch['source_name'];
-            }
-            if (!empty($this->batch['source_page'])) {
-                $meta['source_page'] = $this->batch['source_page'];
-            }
-            if (!empty($this->batch['tags'])) {
-                $meta['tags'] = $this->explodeTags($this->batch['tags']);
-            }
-            if (!empty($this->batch['bundle_key'])) {
-                $meta['bundle_key'] = $this->batch['bundle_key'];
-            }
-            if (!empty($this->batch['expected_count'])) {
-                $meta['expected_count'] = $this->batch['expected_count'];
-            }
-            if (!empty($this->batch['catalog_node_id'])) {
-                $meta['catalog_node_id'] = $this->batch['catalog_node_id'];
-            }
-
-            $paper->update(array_merge($updates, ['meta' => $meta]));
-        }
+        $this->applyBatchToIds($this->selectedIds);
     }
 
     public function autoInfer(): void
@@ -260,7 +448,17 @@ class MarkdownImportWorkbench extends Page
 
         $parsed = $this->parseImportFilename();
         if (!empty($parsed)) {
-            $this->form['textbook_series'] = $this->form['textbook_series'] ?: $parsed['series'];
+            // 关键:推断出的系列必须经过正式化 ID 锁定,否则无法触发联动
+            if (empty($this->form['textbook_series_id']) && !empty($parsed['series'])) {
+                $formal = $this->inferenceService()->resolveSeries($parsed['series']);
+                if ($formal) {
+                    $this->form['textbook_series_id'] = $formal->id;
+                    $this->form['textbook_series'] = $formal->name;
+                } else {
+                    $this->form['textbook_series'] = $parsed['series'];
+                }
+            }
+            
             $this->form['grade'] = $this->form['grade'] ?: $parsed['grade'];
             $this->form['term'] = $this->form['term'] ?: $parsed['term'];
             $this->form['source_name'] = $this->form['source_name'] ?: $parsed['name'];
@@ -270,9 +468,21 @@ class MarkdownImportWorkbench extends Page
         $raw = (string) ($paper->raw_markdown ?? '');
         $context = $title . ' ' . $raw;
 
-        $this->form['term'] = $this->inferTerm($context) ?? $this->form['term'];
-        $this->form['grade'] = $this->inferGrade($context) ?? $this->form['grade'];
-        $this->form['chapter'] = $this->inferChapter($context) ?? $this->form['chapter'];
+        $this->form['term'] = $this->inferenceService()->inferTerm($context) ?? $this->form['term'];
+        $this->form['grade'] = $this->inferenceService()->inferGrade($context) ?? $this->form['grade'];
+        $this->form['chapter'] = $this->inferenceService()->inferChapter($context) ?? $this->form['chapter'];
+        $this->form['source_type'] = $this->inferenceService()->inferSourceType($context) ?? $this->form['source_type'];
+
+        if (empty($this->form['catalog_node_ids'])) {
+            $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $this->form['textbook_id']);
+            if ($matchedCatalog) {
+                $this->form['catalog_node_ids'] = [$matchedCatalog];
+            }
+        }
+
+        // 执行推断后,立即触发教材重新关联和保存,确保“一环扣一环”
+        $this->reInferTextbook();
+        $this->savePaper();
     }
 
     public function autoBundleKey(): void
@@ -308,9 +518,9 @@ class MarkdownImportWorkbench extends Page
         foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) {
             $context = (string) ($paper->title ?? $paper->full_title ?? '') . ' ' . (string) ($paper->raw_markdown ?? '');
             $updates = array_filter([
-                'term' => $this->inferTerm($context),
-                'grade' => $this->inferGrade($context),
-                'chapter' => $this->inferChapter($context),
+                'term' => $this->inferenceService()->inferTerm($context),
+                'grade' => $this->inferenceService()->inferGrade($context),
+                'chapter' => $this->inferenceService()->inferChapter($context),
             ], fn ($value) => $value !== null && $value !== '');
 
             if (!empty($parsed)) {
@@ -329,6 +539,28 @@ class MarkdownImportWorkbench extends Page
             if (!empty($parsed['name']) && empty($meta['source_name'])) {
                 $meta['source_name'] = $parsed['name'];
             }
+            if (empty($meta['catalog_node_ids'])) {
+                $candidateTextbookId = $updates['textbook_id'] ?? $paper->textbook_id ?? null;
+                
+                // 如果没有教材 ID,实时推断一个
+                if (!$candidateTextbookId) {
+                    $best = $this->inferenceService()->findBestTextbook([
+                        'series_id' => $updates['textbook_series_id'] ?? null,
+                        'grade' => $updates['grade'] ?? $paper->grade,
+                        'term' => $updates['term'] ?? $paper->term,
+                    ]);
+                    if ($best) {
+                        $candidateTextbookId = $best->id;
+                        $updates['textbook_id'] = $best->id;
+                    }
+                }
+
+                $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $candidateTextbookId);
+                if ($matchedCatalog) {
+                    $meta['catalog_node_ids'] = [$matchedCatalog];
+                    $meta['catalog_node_id'] = $matchedCatalog;
+                }
+            }
 
             if (!empty($updates)) {
                 $updates['meta'] = $meta;
@@ -338,6 +570,51 @@ class MarkdownImportWorkbench extends Page
             }
         }
     }
+    
+    public function mergeSelectedPapers(): void
+    {
+        if (count($this->selectedIds) < 2) {
+            $this->dispatch('notify', ['type' => 'warning', 'message' => '请至少选择两套卷子进行合并']);
+            return;
+        }
+
+        $papers = SourcePaper::query()
+            ->whereIn('id', $this->selectedIds)
+            ->orderBy('order')
+            ->get();
+
+        $target = $papers->shift(); // 第一套作为目标
+        
+        DB::transaction(function () use ($target, $papers) {
+            foreach ($papers as $source) {
+                // 1. 移动所有候选题目
+                DB::table('pre_question_candidates')
+                    ->where('source_paper_id', $source->id)
+                    ->update(['source_paper_id' => $target->id]);
+
+                // 2. 移动所有 PaperPart (如果需要)
+                DB::table('paper_parts')
+                    ->where('source_paper_id', $source->id)
+                    ->update(['source_paper_id' => $target->id]);
+
+                // 3. 追加 Markdown 内容 (可选,但有助于保持记录完整)
+                $target->raw_markdown .= "\n\n" . $source->raw_markdown;
+                
+                // 4. 删除原卷子
+                $source->delete();
+            }
+            
+            $target->save();
+        });
+
+        $this->selectedIds = [];
+        $this->selectPaper($target->id);
+        
+        Notification::make()
+            ->title('卷子合并成功')
+            ->success()
+            ->send();
+    }
 
     public function selectAllVisible(): void
     {
@@ -378,8 +655,15 @@ class MarkdownImportWorkbench extends Page
 
     public function textbookOptions(): array
     {
-        return Textbook::query()
-            ->orderBy('id')
+        $query = Textbook::query();
+        
+        // 核心联动:基于 series_id 进行物理过滤
+        $seriesId = $this->form['textbook_series_id'] ?? null;
+        if ($seriesId) {
+            $query->where('series_id', $seriesId);
+        }
+
+        return $query->orderBy('id')
             ->get(['id', 'official_title'])
             ->mapWithKeys(function ($textbook) {
                 $title = $textbook->official_title ?: '未命名教材';
@@ -388,13 +672,22 @@ class MarkdownImportWorkbench extends Page
             ->toArray();
     }
 
-    public function catalogOptions(): array
+    public function seriesOptions(): array
     {
-        if (empty($this->form['textbook_id']) && empty($this->batch['textbook_id'])) {
+        return \App\Models\TextbookSeries::query()
+            ->orderBy('sort_order')
+            ->pluck('name', 'id')
+            ->toArray();
+    }
+
+    public function catalogOptions(?int $textbookId = null): array
+    {
+        $textbookId ??= $this->form['textbook_id'] ?? null;
+        
+        if (empty($textbookId)) {
             return [];
         }
 
-        $textbookId = $this->form['textbook_id'] ?: $this->batch['textbook_id'];
         $nodes = TextbookCatalog::query()
             ->where('textbook_id', $textbookId)
             ->orderBy('sort_order')
@@ -432,9 +725,11 @@ class MarkdownImportWorkbench extends Page
             ->where('textbook_id', $textbookId)
             ->get(['meta'])
             ->each(function ($paper) use (&$coverage) {
-                $catalogId = $paper->meta['catalog_node_id'] ?? null;
-                if ($catalogId) {
-                    $coverage[$catalogId] = ($coverage[$catalogId] ?? 0) + 1;
+                $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []);
+                foreach ((array) $ids as $id) {
+                    if ($id) {
+                        $coverage[$id] = ($coverage[$id] ?? 0) + 1;
+                    }
                 }
             });
 
@@ -466,9 +761,11 @@ class MarkdownImportWorkbench extends Page
             ->where('textbook_id', $textbookId)
             ->get(['meta'])
             ->each(function ($paper) use (&$coverage) {
-                $catalogId = $paper->meta['catalog_node_id'] ?? null;
-                if ($catalogId) {
-                    $coverage[$catalogId] = ($coverage[$catalogId] ?? 0) + 1;
+                $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []);
+                foreach ((array) $ids as $id) {
+                    if ($id) {
+                        $coverage[$id] = ($coverage[$id] ?? 0) + 1;
+                    }
                 }
             });
 
@@ -485,6 +782,32 @@ class MarkdownImportWorkbench extends Page
         return array_slice($missing, 0, 8);
     }
 
+    public function catalogTitlesForPaper(SourcePaper|array $paper): array
+    {
+        $meta = $paper instanceof SourcePaper ? ($paper->meta ?? []) : ($paper['meta'] ?? []);
+        $textbookId = $paper instanceof SourcePaper ? $paper->textbook_id : ($paper['textbook_id'] ?? null);
+        if (empty($textbookId)) {
+            return [];
+        }
+
+        $ids = $this->normalizeCatalogNodeIds($meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? []));
+        if (empty($ids)) {
+            return [];
+        }
+
+        $missing = array_values(array_diff($ids, array_keys($this->catalogNodeCache)));
+        if (!empty($missing)) {
+            TextbookCatalog::query()
+                ->whereIn('id', $missing)
+                ->get(['id', 'title'])
+                ->each(function ($node) {
+                    $this->catalogNodeCache[(int) $node->id] = $node->title ?: ('目录 #' . $node->id);
+                });
+        }
+
+        return array_values(array_filter(array_map(fn ($id) => $this->catalogNodeCache[$id] ?? null, $ids)));
+    }
+
     public function textbookSuggestions(): array
     {
         $paper = $this->selectedPaper();
@@ -500,64 +823,13 @@ class MarkdownImportWorkbench extends Page
         $seriesHint = $paper->textbook_series ?: ($parsed['series'] ?? null);
         $subjectHint = $parsed['subject'] ?? null;
 
-        $suggestions = [];
-        $textbooks = Textbook::query()->with('series')->get();
-        foreach ($textbooks as $textbook) {
-            $score = 0;
-
-            if ($grade && (int) $textbook->grade === $grade) {
-                $score += 3;
-            }
-            if ($semester && (int) $textbook->semester === $semester) {
-                $score += 3;
-            }
-
-            $official = mb_strtolower((string) $textbook->official_title);
-            if ($official !== '' && str_contains($context, $official)) {
-                $score += 4;
-            }
-
-            $aliases = $textbook->aliases ?? [];
-            foreach ($aliases as $alias) {
-                $alias = mb_strtolower((string) $alias);
-                if ($alias !== '' && str_contains($context, $alias)) {
-                    $score += 2;
-                }
-            }
-
-            $seriesName = $textbook->series?->name ?? null;
-            if ($seriesHint && $seriesName && str_contains((string) $seriesHint, (string) $seriesName)) {
-                $score += 5;
-            }
-
-            if ($subjectHint) {
-                $subjectHint = mb_strtolower((string) $subjectHint);
-                $official = mb_strtolower((string) $textbook->official_title);
-                if ($official !== '' && str_contains($official, $subjectHint)) {
-                    $score += 1;
-                }
-            }
-
-            if ($score > 0) {
-                $suggestions[] = [
-                    'id' => $textbook->id,
-                    'title' => $textbook->official_title,
-                    'series' => $textbook->series?->name ?? '未归类系列',
-                    'grade' => $textbook->grade,
-                    'semester' => $textbook->semester,
-                    'score' => $score,
-                ];
-            }
-        }
-
-        usort($suggestions, fn ($a, $b) => $b['score'] <=> $a['score']);
-        return array_slice($suggestions, 0, 5);
+        return $this->inferenceService()->getTextbookSuggestions($paper, $parsed);
     }
 
     public function catalogSuggestions(): array
     {
         $paper = $this->selectedPaper();
-        $textbookId = $paper?->textbook_id ?? $this->form['textbook_id'];
+        $textbookId = $paper?->textbook_id ?? ($this->form['textbook_id'] ?? null);
         if (!$paper || !$textbookId) {
             return [];
         }
@@ -596,7 +868,12 @@ class MarkdownImportWorkbench extends Page
 
     public function applyCatalogSuggestion(int $catalogId): void
     {
-        $this->form['catalog_node_id'] = $catalogId;
+        $ids = $this->form['catalog_node_ids'] ?? [];
+        if (!in_array($catalogId, $ids)) {
+            $ids[] = $catalogId;
+            $this->form['catalog_node_ids'] = $ids;
+            $this->savePaper();
+        }
     }
 
     public function candidateCountFor(SourcePaper $paper): int
@@ -604,33 +881,39 @@ class MarkdownImportWorkbench extends Page
         return (int) ($paper->candidates_count ?? 0);
     }
 
-    private function inferTerm(string $context): ?string
+    public function checkCompleteness(): array
     {
-        if (str_contains($context, '上册') || str_contains($context, '上学期')) {
-            return '上册';
-        }
-        if (str_contains($context, '下册') || str_contains($context, '下学期')) {
-            return '下册';
+        $paper = $this->selectedPaper();
+        if (!$paper) {
+            return [];
         }
-        return null;
-    }
 
-    private function inferGrade(string $context): ?string
-    {
-        foreach (['七年级' => '7', '八年级' => '8', '九年级' => '9', '高一' => '10', '高二' => '11', '高三' => '12'] as $label => $value) {
-            if (str_contains($context, $label)) {
-                return $value;
+        $candidates = $paper->candidates()
+            ->where('is_question_candidate', true)
+            ->get();
+
+        $service = app(QuestionCandidateToQuestionService::class);
+        $total = $candidates->count();
+        $invalid = 0;
+        $issueStats = [];
+
+        foreach ($candidates as $candidate) {
+            $errors = $service->validateCandidate($candidate);
+            if (!empty($errors)) {
+                $invalid++;
+                foreach ($errors as $error) {
+                    $issueStats[$error] = ($issueStats[$error] ?? 0) + 1;
+                }
             }
         }
-        return null;
-    }
 
-    private function inferChapter(string $context): ?string
-    {
-        if (preg_match('/第[一二三四五六七八九十]+章[^\\n]*/u', $context, $match)) {
-            return $match[0];
-        }
-        return null;
+        return [
+            'total' => $total,
+            'valid' => $total - $invalid,
+            'invalid' => $invalid,
+            'issues' => $issueStats,
+            'is_ready' => $total > 0 && $invalid === 0,
+        ];
     }
 
     private function explodeTags(string $tags): array
@@ -640,16 +923,7 @@ class MarkdownImportWorkbench extends Page
 
     private function termToSemester(?string $term): ?int
     {
-        if (!$term) {
-            return null;
-        }
-        if (str_contains($term, '上')) {
-            return 1;
-        }
-        if (str_contains($term, '下')) {
-            return 2;
-        }
-        return null;
+        return $this->inferenceService()->termToSemester($term);
     }
 
     private function buildBundleKey(SourcePaper $paper): string
@@ -693,28 +967,51 @@ class MarkdownImportWorkbench extends Page
         }
 
         $parsed = $this->filenameParsed;
+        $resolvedTextbook = $this->inferenceService()->resolveTextbookFromFilename($parsed);
         $papers = SourcePaper::query()
             ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId))
             ->get();
 
         foreach ($papers as $paper) {
             $meta = $paper->meta ?? [];
-            if (!empty($meta['filename_defaults_applied'])) {
+            $hasNumericGrade = is_numeric((string) $paper->grade);
+            $hasAllDefaults = !empty($paper->textbook_id)
+                && !empty($paper->textbook_series)
+                && $hasNumericGrade
+                && !empty($paper->term);
+            if (!empty($meta['filename_defaults_applied']) && $hasAllDefaults) {
                 continue;
             }
 
             $updates = [];
 
             if (empty($paper->textbook_series) && !empty($parsed['series'])) {
-                $updates['textbook_series'] = $parsed['series'];
+                $formal = $this->inferenceService()->resolveSeries($parsed['series']);
+                $updates['textbook_series_id'] = $formal ? $formal->id : null;
+                $updates['textbook_series'] = $formal ? $formal->name : $parsed['series'];
             }
-            if (empty($paper->grade) && !empty($parsed['grade'])) {
+            
+            if (!empty($parsed['grade']) && empty($paper->grade)) {
                 $updates['grade'] = $parsed['grade'];
             }
-            if (empty($paper->term) && !empty($parsed['term'])) {
+            
+            if (!empty($parsed['term']) && empty($paper->term)) {
                 $updates['term'] = $parsed['term'];
             }
 
+            // 在应用文件名默认值时,也触发一次教材重定向
+            if (empty($paper->textbook_id)) {
+                $best = $this->inferenceService()->findBestTextbook([
+                    'series_id' => $updates['textbook_series_id'] ?? null,
+                    'textbook_series' => $updates['textbook_series'] ?? $paper->textbook_series,
+                    'grade' => $updates['grade'] ?? $paper->grade,
+                    'term' => $updates['term'] ?? $paper->term,
+                ]);
+                if ($best) {
+                    $updates['textbook_id'] = $best->id;
+                }
+            }
+            
             if (empty($meta['source_name']) && !empty($parsed['name'])) {
                 $meta['source_name'] = $parsed['name'];
             }
@@ -723,9 +1020,6 @@ class MarkdownImportWorkbench extends Page
                 $meta['filename_defaults_applied'] = true;
                 $updates['meta'] = $meta;
                 $paper->update($updates);
-            } elseif (!empty($meta)) {
-                $meta['filename_defaults_applied'] = true;
-                $paper->update(['meta' => $meta]);
             }
         }
     }
@@ -735,4 +1029,117 @@ class MarkdownImportWorkbench extends Page
         $import = $this->importRecord();
         return $import?->parseFilename() ?? [];
     }
+
+    private function normalizeGradeForForm($grade, array $parsed): ?int
+    {
+        if (is_numeric($grade)) {
+            return (int) $grade;
+        }
+
+        $map = [
+            '七年级' => 7,
+            '八年级' => 8,
+            '九年级' => 9,
+            '高一' => 10,
+            '高二' => 11,
+            '高三' => 12,
+        ];
+
+        $grade = is_string($grade) ? trim($grade) : '';
+        if ($grade !== '' && isset($map[$grade])) {
+            return $map[$grade];
+        }
+
+        $parsedGrade = $parsed['grade'] ?? null;
+        return is_numeric($parsedGrade) ? (int) $parsedGrade : null;
+    }
+
+    private function normalizeCatalogNodeIds($value): array
+    {
+        $ids = is_array($value) ? $value : [$value];
+        $ids = array_values(array_unique(array_map('intval', array_filter($ids))));
+        return $ids;
+    }
+
+    private function filterCatalogNodeIdsForTextbook(?int $textbookId, array $ids): array
+    {
+        $ids = $this->normalizeCatalogNodeIds($ids);
+        if (empty($textbookId) || empty($ids)) {
+            return $ids;
+        }
+
+        $validIds = TextbookCatalog::query()
+            ->where('textbook_id', $textbookId)
+            ->whereIn('id', $ids)
+            ->pluck('id')
+            ->map(fn ($id) => (int) $id)
+            ->toArray();
+
+        return $validIds;
+    }
+
+    private function applyBatchToIds(array $ids): void
+    {
+        $targetIds = array_values(array_filter(array_unique(array_map('intval', $ids))));
+        if (empty($targetIds)) {
+            return;
+        }
+
+        $updates = array_filter([
+            'edition' => $this->batch['edition'] ?? null,
+            'grade' => $this->batch['grade'] ?? null,
+            'term' => $this->batch['term'] ?? null,
+            'chapter' => $this->batch['chapter'] ?? null,
+            'source_type' => $this->batch['source_type'] ?? null,
+            'source_year' => $this->batch['source_year'] ?? null,
+            'textbook_id' => $this->batch['textbook_id'] ?? null,
+            'series_id' => $this->batch['textbook_series_id'] ?? null,
+        ], fn ($value) => $value !== null && $value !== '');
+
+        foreach (SourcePaper::query()->whereIn('id', $targetIds)->get() as $paper) {
+            $meta = $paper->meta ?? [];
+
+            if (!empty($this->batch['source_name'])) {
+                $meta['source_name'] = $this->batch['source_name'];
+            }
+            if (!empty($this->batch['source_page'])) {
+                $meta['source_page'] = $this->batch['source_page'];
+            }
+            if (!empty($this->batch['tags'])) {
+                $meta['tags'] = $this->explodeTags($this->batch['tags']);
+            }
+            if (!empty($this->batch['bundle_key'])) {
+                $meta['bundle_key'] = $this->batch['bundle_key'];
+            }
+            if (!empty($this->batch['expected_count'])) {
+                $meta['expected_count'] = $this->batch['expected_count'];
+            }
+            if (!empty($this->batch['catalog_node_ids'])) {
+                $catalogNodeIds = $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids']);
+                $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? $paper->textbook_id, $catalogNodeIds);
+                $meta['catalog_node_ids'] = $catalogNodeIds;
+                $meta['catalog_node_id'] = $catalogNodeIds[0] ?? null;
+            }
+
+            $paper->update(array_merge($updates, ['meta' => $meta]));
+        }
+    }
+
+    private function isBatchEmpty(): bool
+    {
+        $fields = [
+            'edition', 'grade', 'term', 'chapter', 'source_type',
+            'source_year', 'textbook_id', 'textbook_series_id',
+            'source_name', 'source_page', 'tags', 'bundle_key',
+            'expected_count',
+        ];
+
+        foreach ($fields as $field) {
+            if (!empty($this->batch[$field])) {
+                return false;
+            }
+        }
+
+        return empty($this->batch['catalog_node_ids']);
+    }
 }

+ 3 - 0
app/Filament/Pages/PromptManagement.php

@@ -128,6 +128,9 @@ class PromptManagement extends Page
     public function getTypeOptions(): array
     {
         return [
+            'question_generation' => '题目生成(系统)',
+            'question_enrich' => '题干补全(系统)',
+            'question_solution_regen' => '解题重写(系统)',
             '题目生成' => '题目生成',
             '掌握度评估' => '掌握度评估',
             '技能熟练度' => '技能熟练度',

+ 96 - 0
app/Filament/Pages/QuestionDetail.php

@@ -5,6 +5,9 @@ namespace App\Filament\Pages;
 use App\Services\QuestionServiceApi;
 use App\Services\MistakeBookService;
 use App\Services\KnowledgeGraphService;
+use App\Services\QuestionPromptService;
+use App\Services\AiClientService;
+use App\Models\Question;
 use App\Models\Student;
 use Filament\Notifications\Notification;
 use Filament\Pages\Page;
@@ -31,6 +34,7 @@ class QuestionDetail extends Page
     public array $relatedQuestions = [];
     public ?string $studentName = null;
     public array $historySummary = [];
+    public string $answerOverride = '';
 
     public function mount(?string $questionId = null): void
     {
@@ -67,6 +71,7 @@ class QuestionDetail extends Page
         $question = $this->fetchQuestion($this->questionId);
         if ($question) {
             $this->questionData = $this->prepareQuestionDisplay($question);
+            $this->answerOverride = (string) ($this->questionData['answer'] ?? '');
             $this->attachGlobalAccuracy();
         }
     }
@@ -109,6 +114,7 @@ class QuestionDetail extends Page
                 'created_at' => $detail['created_at'] ?? '',
             ],
         ]);
+        $this->answerOverride = (string) ($this->questionData['answer'] ?? '');
 
         // 尝试从本地学生表补充姓名,避免仅展示ID
         if (!$this->studentName && $this->studentId) {
@@ -231,6 +237,96 @@ class QuestionDetail extends Page
         return '困难';
     }
 
+    public function saveAnswerOverride(): void
+    {
+        if (!$this->questionId) {
+            return;
+        }
+
+        $question = Question::query()->find($this->questionId);
+        if (!$question) {
+            Notification::make()
+                ->title('题目不存在')
+                ->danger()
+                ->send();
+            return;
+        }
+
+        $question->update(['answer' => $this->answerOverride]);
+        $this->questionData['answer'] = $this->answerOverride;
+
+        Notification::make()
+            ->title('答案已更新')
+            ->success()
+            ->send();
+    }
+
+    public function regenerateSolution(): void
+    {
+        if (!$this->questionId) {
+            return;
+        }
+
+        $question = Question::query()->find($this->questionId);
+        if (!$question) {
+            Notification::make()
+                ->title('题目不存在')
+                ->danger()
+                ->send();
+            return;
+        }
+
+        $stem = (string) ($this->questionData['stem'] ?? '');
+        $answer = (string) ($this->answerOverride ?: ($this->questionData['answer'] ?? ''));
+        $images = $this->questionData['images']
+            ?? ($this->questionData['meta']['images'] ?? []);
+
+        if (is_string($images)) {
+            $decoded = json_decode($images, true);
+            $images = is_array($decoded) ? $decoded : [];
+        }
+
+        if ($stem === '' || $answer === '') {
+            Notification::make()
+                ->title('题干或答案为空,无法生成')
+                ->warning()
+                ->send();
+            return;
+        }
+
+        try {
+            $prompt = app(QuestionPromptService::class)->buildSolutionRegenPrompt($stem, $answer, $images);
+            $result = app(AiClientService::class)->callJson($prompt);
+
+            $solution = $result['solution'] ?? '';
+            $steps = $result['steps'] ?? [];
+
+            $meta = $question->meta ?? [];
+            $meta['solution_steps'] = $steps;
+
+            $question->update([
+                'solution' => $solution,
+                'answer' => $answer,
+                'meta' => $meta,
+            ]);
+
+            $this->questionData['solution'] = $solution;
+            $this->questionData['answer'] = $answer;
+            $this->questionData['meta']['solution_steps'] = $steps;
+
+            Notification::make()
+                ->title('解题思路已更新')
+                ->success()
+                ->send();
+        } catch (\Throwable $e) {
+            Notification::make()
+                ->title('AI 生成失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+        }
+    }
+
     public function getKnowledgePointName(): string
     {
         if (!isset($this->questionData['kp_code'])) {

+ 6 - 3
app/Filament/Pages/QuestionManagement.php

@@ -90,9 +90,9 @@ class QuestionManagement extends Page
             'CHOICE' => '单选题',
             'MULTIPLE_CHOICE' => '多选题',
             'FILL_IN_THE_BLANK' => '填空题',
-            'CALCULATION' => '简单题',
-            'WORD_PROBLEM' => '简单题',
-            'PROOF' => '简单题',
+            'CALCULATION' => '解答题',
+            'WORD_PROBLEM' => '应用题',
+            'PROOF' => '证明题',
         ];
     }
 
@@ -141,8 +141,11 @@ class QuestionManagement extends Page
                 // 将中文类型名映射为英文类型名
                 $typeMapping = [
                     '选择题' => 'CHOICE',
+                    '多选题' => 'MULTIPLE_CHOICE',
                     '填空题' => 'FILL_IN_THE_BLANK',
                     '解答题' => 'CALCULATION',
+                    '证明题' => 'PROOF',
+                    '其他' => 'OTHER',
                 ];
                 $englishType = $typeMapping[$typeName] ?? 'CALCULATION';
                 $result[$englishType] = $count;

+ 10 - 2
app/Filament/Pages/QuestionReviewWorkbench.php

@@ -75,13 +75,21 @@ class QuestionReviewWorkbench extends Page
         $this->selectedIds = [];
     }
 
-    public function approve(int $candidateId, QuestionReviewService $service): void
+    public function approve(?int $candidateId = null, QuestionReviewService $service): void
     {
+        if (!$candidateId) {
+            return;
+        }
+
         $service->promoteCandidateToQuestion($candidateId);
     }
 
-    public function reject(int $candidateId): void
+    public function reject(?int $candidateId = null): void
     {
+        if (!$candidateId) {
+            return;
+        }
+
         PreQuestionCandidate::where('id', $candidateId)->update([
             'status' => PreQuestionCandidate::STATUS_REJECTED,
         ]);

+ 13 - 2
app/Filament/Resources/MarkdownImportResource.php

@@ -217,6 +217,15 @@ class MarkdownImportResource extends Resource
                     ->searchable()
                     ->sortable(),
 
+                TextColumn::make('remote_url')
+                    ->label('源文件')
+                    ->getStateUsing(fn (?Model $record) => $record?->remote_url ? '查看' : '—')
+                    ->icon('heroicon-o-document-arrow-down')
+                    ->color('primary')
+                    ->url(fn (?Model $record) => $record?->remote_url)
+                    ->openUrlInNewTab()
+                    ->toggleable(),
+
                 TextColumn::make('filename_parse_status')
                     ->label('命名解析')
                     ->badge()
@@ -492,8 +501,10 @@ class MarkdownImportResource extends Resource
         // 让 parsed_count / accepted_count 成为可排序的 SQL 字段(避免 order by accessor 报错)
         return parent::getEloquentQuery()
             ->withCount([
-                'candidates as parsed_count',
-                'candidates as accepted_count' => fn (Builder $query) => $query->where('is_question_candidate', true),
+                'candidates as parsed_count' => fn (Builder $query) => $query->where('status', '!=', 'superseded'),
+                'candidates as accepted_count' => fn (Builder $query) => $query
+                    ->where('status', '!=', 'superseded')
+                    ->where('is_question_candidate', true),
             ]);
     }
 

+ 44 - 0
app/Filament/Resources/PaperPartResource/Pages/ViewPaperPart.php

@@ -3,9 +3,53 @@
 namespace App\Filament\Resources\PaperPartResource\Pages;
 
 use App\Filament\Resources\PaperPartResource;
+use App\Models\PaperQuestionRef;
+use App\Models\PreQuestionCandidate;
+use App\Services\QuestionExtractorService;
+use Filament\Actions\Action;
+use Filament\Notifications\Notification;
 use Filament\Resources\Pages\ViewRecord;
+use Illuminate\Support\Facades\DB;
 
 class ViewPaperPart extends ViewRecord
 {
     protected static string $resource = PaperPartResource::class;
+
+    protected function getHeaderActions(): array
+    {
+        return [
+            Action::make('rebuild_candidates')
+                ->label('重新拆题')
+                ->color('warning')
+                ->requiresConfirmation()
+                ->action(function (): void {
+                    $part = $this->record;
+                    $raw = trim((string) ($part->raw_markdown ?? ''));
+                    if ($raw === '') {
+                        Notification::make()
+                            ->title('无法拆题')
+                            ->body('该区块没有原始 Markdown 内容')
+                            ->danger()
+                            ->send();
+                        return;
+                    }
+
+                    DB::transaction(function () use ($part): void {
+                        PaperQuestionRef::where('part_id', $part->id)->delete();
+                        PreQuestionCandidate::where('part_id', $part->id)->delete();
+
+                        $sequence = 1;
+                        app(QuestionExtractorService::class)->extractAndPersist($part, null, $sequence);
+                    });
+
+                    $part->refresh();
+
+                    Notification::make()
+                        ->title('拆题完成')
+                        ->body('已根据最新规则重新生成题目候选')
+                        ->success()
+                        ->send();
+                }),
+        ];
+    }
 }

+ 5 - 1
app/Filament/Resources/PaperPartResource/RelationManagers/PreQuestionCandidatesRelationManager.php

@@ -26,7 +26,11 @@ class PreQuestionCandidatesRelationManager extends RelationManager
             ])
             ->actions([
                 ViewAction::make()
-                    ->url(fn ($record) => route('filament.admin.resources.pre-question-candidates.edit', $record)),
+                    ->label('查看候选')
+                    ->url(fn ($record) => route('filament.admin.resources.pre-question-candidates.index', [
+                        'import_id' => $record->import_id,
+                    ]))
+                    ->openUrlInNewTab(),
             ])
             ->headerActions([]);
     }

+ 42 - 4
app/Filament/Resources/SourcePaperResource.php

@@ -5,13 +5,20 @@ namespace App\Filament\Resources;
 use App\Filament\Resources\SourcePaperResource\Pages;
 use App\Filament\Resources\SourcePaperResource\RelationManagers\PaperPartsRelationManager;
 use App\Filament\Resources\SourcePaperResource\RelationManagers\PreQuestionCandidatesRelationManager;
+use App\Filament\Resources\SourcePaperResource\Actions\GenerateQuestionsBulkAction;
 use App\Models\SourcePaper;
+use App\Services\QuestionCandidateToQuestionService;
 use Filament\Forms;
 use Filament\Resources\Resource;
 use Filament\Schemas\Schema;
 use Filament\Tables;
 use Filament\Tables\Table;
 use Filament\Actions\ViewAction;
+use Filament\Actions\Action;
+use Filament\Actions\BulkActionGroup;
+use Filament\Notifications\Notification;
+use App\Jobs\PromoteSourcePapersJob;
+use App\Services\TaskManager;
 use Illuminate\Database\Eloquent\Model;
 use BackedEnum;
 use UnitEnum;
@@ -25,7 +32,7 @@ class SourcePaperResource extends Resource
 {
     protected static ?string $model = SourcePaper::class;
 
-    protected static bool $shouldRegisterNavigation = false;
+    protected static bool $shouldRegisterNavigation = true;
 
     protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-text';
 
@@ -33,7 +40,17 @@ class SourcePaperResource extends Resource
 
     protected static ?string $navigationLabel = '源卷子列表';
 
-    protected static ?int $navigationSort = 3;
+    protected static ?int $navigationSort = 99;
+
+    public static function shouldRegisterNavigation(): bool
+    {
+        return true;
+    }
+
+    public static function canViewAny(): bool
+    {
+        return true;
+    }
 
     public static function canCreate(): bool
     {
@@ -64,9 +81,8 @@ class SourcePaperResource extends Resource
     {
         return $table
             ->columns([
-                Tables\Columns\TextColumn::make('order')->label('顺序')->sortable(),
+                Tables\Columns\TextColumn::make('id')->label('ID')->sortable(),
                 Tables\Columns\TextColumn::make('title')->label('卷标题')->searchable(),
-                Tables\Columns\TextColumn::make('file.original_filename')->label('来源文件')->toggleable(),
                 Tables\Columns\TextColumn::make('grade')->label('年级'),
                 Tables\Columns\TextColumn::make('term')->label('学期'),
                 Tables\Columns\TextColumn::make('source_type')->label('类型'),
@@ -78,6 +94,7 @@ class SourcePaperResource extends Resource
                     ->label('区块数')
                     ->getStateUsing(fn (Model $record) => $record->parts_count ?? 0)
                     ->sortable(),
+                Tables\Columns\TextColumn::make('file.original_filename')->label('来源文件')->toggleable(),
             ])
             ->filters([
                 SelectFilter::make('grade')
@@ -119,6 +136,27 @@ class SourcePaperResource extends Resource
                     ->icon('heroicon-o-eye')
                     ->iconButton()
                     ->tooltip('查看详情'),
+                Action::make('generate_questions')
+                    ->label('入库题库')
+                    ->icon('heroicon-o-sparkles')
+                    ->requiresConfirmation()
+                    ->action(function (SourcePaper $record) {
+                        $taskId = app(TaskManager::class)->createTask(
+                            TaskManager::TASK_TYPE_ANALYSIS,
+                            ['type' => 'source_paper_import', 'paper_ids' => [$record->id]]
+                        );
+                        PromoteSourcePapersJob::dispatch($taskId, [$record->id]);
+
+                        Notification::make()
+                            ->title("已加入队列,任务号:{$taskId}")
+                            ->success()
+                            ->send();
+                    }),
+            ])
+            ->bulkActions([
+                BulkActionGroup::make([
+                    GenerateQuestionsBulkAction::make(),
+                ]),
             ])
             ->recordUrl(fn (Model $record): string => route('filament.admin.resources.source-papers.view', $record));
     }

+ 68 - 0
app/Filament/Resources/SourcePaperResource/Actions/GenerateQuestionsBulkAction.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Filament\Resources\SourcePaperResource\Actions;
+
+use App\Jobs\PromoteSourcePapersJob;
+use App\Services\TaskManager;
+use Filament\Actions\BulkAction;
+use Filament\Notifications\Notification;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\Log;
+
+class GenerateQuestionsBulkAction extends BulkAction
+{
+    public function getName(): string
+    {
+        return 'generate_questions';
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->label('一键生成题库');
+        $this->color('primary');
+        $this->icon('heroicon-o-sparkles');
+        $this->requiresConfirmation();
+        $this->modalHeading('一键生成题库');
+        $this->modalDescription('将选中的源卷子下已校对题目入库到题库,并上传图片到春笋云。');
+
+        $this->action(function (Collection $records) {
+            $this->generateQuestions($records);
+        });
+    }
+
+    private function generateQuestions(Collection $records): void
+    {
+        try {
+            $paperIds = $records->pluck('id')->map(fn ($id) => (int) $id)->all();
+            if (empty($paperIds)) {
+                Notification::make()
+                    ->title('未选择任何卷子')
+                    ->warning()
+                    ->send();
+                return;
+            }
+
+            $taskId = app(TaskManager::class)->createTask(
+                TaskManager::TASK_TYPE_ANALYSIS,
+                ['type' => 'source_paper_import', 'paper_ids' => $paperIds]
+            );
+            PromoteSourcePapersJob::dispatch($taskId, $paperIds);
+
+            Notification::make()
+                ->title("已加入队列,任务号:{$taskId}")
+                ->success()
+                ->send();
+        } catch (\Throwable $e) {
+            Log::error('Generate questions bulk action failed', [
+                'error' => $e->getMessage(),
+            ]);
+
+            Notification::make()
+                ->title('入库失败:' . $e->getMessage())
+                ->danger()
+                ->send();
+        }
+    }
+}

+ 434 - 0
app/Http/Controllers/Api/ExamAnswerAnalysisController.php

@@ -0,0 +1,434 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Services\ExamAnswerAnalysisService;
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Validator;
+
+/**
+ * 考试答题分析 API 控制器
+ * 支持步骤级分析的 RESTful API
+ */
+class ExamAnswerAnalysisController extends Controller
+{
+    public function __construct(
+        private readonly ExamAnswerAnalysisService $analysisService
+    ) {}
+
+    /**
+     * 分析考试答题数据
+     *
+     * POST /api/exam-answer-analysis
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function analyze(Request $request): JsonResponse
+    {
+        try {
+            // 验证请求数据
+            $validator = Validator::make($request->all(), [
+                'exam_id' => 'required|string|max:255',
+                'student_id' => 'required|string|max:255',
+                'questions' => 'required|array|min:1',
+                'questions.*.question_id' => 'required|string|max:255',
+                'questions.*.score' => 'required|numeric|min:0',
+                'questions.*.score_obtained' => 'required|numeric|min:0',
+                'questions.*.steps' => 'sometimes|array',
+                'questions.*.steps.*.step_index' => 'required_with:questions.*.steps|integer|min:1',
+                'questions.*.steps.*.is_correct' => 'required_with:questions.*.steps|boolean',
+                'questions.*.steps.*.kp_id' => 'required_with:questions.*.steps|string|max:255',
+                'questions.*.steps.*.score' => 'required_with:questions.*.steps|numeric|min:0',
+            ]);
+
+            if ($validator->fails()) {
+                return response()->json([
+                    'success' => false,
+                    'error' => 'Validation failed',
+                    'details' => $validator->errors()
+                ], 422);
+            }
+
+            $examData = $request->only(['exam_id', 'student_id', 'questions']);
+
+            // 调用分析服务
+            $result = $this->analysisService->analyzeExamAnswers($examData);
+
+            return response()->json([
+                'success' => true,
+                'data' => $result,
+                'message' => '分析完成'
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('考试答题分析失败', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+                'request_data' => $request->all()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '分析失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取分析结果
+     *
+     * GET /api/exam-answer-analysis/{student_id}/{exam_id}
+     *
+     * @param string $studentId
+     * @param string $examId
+     * @return JsonResponse
+     */
+    public function getAnalysisResult(string $studentId, string $examId): JsonResponse
+    {
+        try {
+            $result = \DB::connection('pgsql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->where('exam_id', $examId)
+                ->orderBy('created_at', 'desc')
+                ->first();
+
+            if (!$result) {
+                return response()->json([
+                    'success' => false,
+                    'error' => '未找到分析结果'
+                ], 404);
+            }
+
+            return response()->json([
+                'success' => true,
+                'data' => json_decode($result->analysis_data, true)
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取分析结果失败', [
+                'student_id' => $studentId,
+                'exam_id' => $examId,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '获取分析结果失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取学生历史分析记录
+     *
+     * GET /api/exam-answer-analysis/history/{student_id}
+     *
+     * @param string $studentId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getHistory(string $studentId, Request $request): JsonResponse
+    {
+        try {
+            $limit = $request->input('limit', 10);
+            $offset = $request->input('offset', 0);
+
+            $results = \DB::connection('pgsql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->orderBy('created_at', 'desc')
+                ->offset($offset)
+                ->limit($limit)
+                ->get()
+                ->map(function ($item) {
+                    return [
+                        'exam_id' => $item->exam_id,
+                        'created_at' => $item->created_at,
+                        'analysis_summary' => json_decode($item->analysis_data, true)['overall_summary'] ?? null
+                    ];
+                });
+
+            return response()->json([
+                'success' => true,
+                'data' => $results,
+                'pagination' => [
+                    'limit' => $limit,
+                    'offset' => $offset,
+                    'total' => \DB::connection('pgsql')
+                        ->table('exam_analysis_results')
+                        ->where('student_id', $studentId)
+                        ->count()
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取历史分析记录失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '获取历史记录失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取知识点掌握度趋势
+     *
+     * GET /api/exam-answer-analysis/mastery-trend/{student_id}
+     *
+     * @param string $studentId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getMasteryTrend(string $studentId, Request $request): JsonResponse
+    {
+        try {
+            $kpCode = $request->input('kp_code');
+
+            $query = \DB::connection('pgsql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->orderBy('created_at', 'desc');
+
+            if ($kpCode) {
+                $query->whereRaw("analysis_data->>'kp_code' = ?", [$kpCode]);
+            }
+
+            $results = $query->limit(20)->get();
+
+            $trendData = [];
+            foreach ($results as $result) {
+                $analysisData = json_decode($result->analysis_data, true);
+                if (isset($analysisData['mastery_vector'])) {
+                    foreach ($analysisData['mastery_vector'] as $kpId => $data) {
+                        if (!$kpCode || $kpId === $kpCode) {
+                            $trendData[$kpId][] = [
+                                'date' => $result->created_at,
+                                'mastery' => $data['current_mastery'],
+                                'change' => $data['change'] ?? 0
+                            ];
+                        }
+                    }
+                }
+            }
+
+            return response()->json([
+                'success' => true,
+                'data' => $trendData
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取掌握度趋势失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '获取趋势数据失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取智能出卷推荐
+     *
+     * GET /api/exam-answer-analysis/smart-quiz/{student_id}
+     *
+     * @param string $studentId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getSmartQuizRecommendation(string $studentId, Request $request): JsonResponse
+    {
+        try {
+            // 获取最近一次分析结果
+            $latestAnalysis = \DB::connection('pgsql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->orderBy('created_at', 'desc')
+                ->first();
+
+            if (!$latestAnalysis) {
+                return response()->json([
+                    'success' => false,
+                    'error' => '未找到分析记录,无法生成推荐'
+                ], 404);
+            }
+
+            $analysisData = json_decode($latestAnalysis->analysis_data, true);
+            $recommendation = $analysisData['smart_quiz_recommendation'] ?? null;
+
+            if (!$recommendation) {
+                return response()->json([
+                    'success' => false,
+                    'error' => '未找到推荐数据'
+                ], 404);
+            }
+
+            return response()->json([
+                'success' => true,
+                'data' => $recommendation,
+                'based_on_exam' => $latestAnalysis->exam_id,
+                'generated_at' => $latestAnalysis->created_at
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取智能出卷推荐失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '获取推荐失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 导出分析报告
+     *
+     * GET /api/exam-answer-analysis/export/{student_id}/{exam_id}
+     *
+     * @param string $studentId
+     * @param string $examId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function export(string $studentId, string $examId, Request $request): JsonResponse
+    {
+        try {
+            $format = $request->input('format', 'json'); // json, pdf
+
+            $result = \DB::connection('pgsql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->where('exam_id', $examId)
+                ->orderBy('created_at', 'desc')
+                ->first();
+
+            if (!$result) {
+                return response()->json([
+                    'success' => false,
+                    'error' => '未找到分析结果'
+                ], 404);
+            }
+
+            $analysisData = json_decode($result->analysis_data, true);
+
+            if ($format === 'pdf') {
+                // TODO: 实现 PDF 导出
+                return response()->json([
+                    'success' => false,
+                    'error' => 'PDF 导出功能尚未实现'
+                ], 501);
+            }
+
+            return response()->json([
+                'success' => true,
+                'data' => $analysisData,
+                'metadata' => [
+                    'student_id' => $studentId,
+                    'exam_id' => $examId,
+                    'generated_at' => $result->created_at,
+                    'format' => $format
+                ]
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('导出分析报告失败', [
+                'student_id' => $studentId,
+                'exam_id' => $examId,
+                'error' => $e->getMessage()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '导出失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+
+    /**
+     * 批量分析多个学生的考试数据
+     *
+     * POST /api/exam-answer-analysis/batch
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function batchAnalyze(Request $request): JsonResponse
+    {
+        try {
+            $validator = Validator::make($request->all(), [
+                'exam_data_list' => 'required|array|min:1',
+                'exam_data_list.*.exam_id' => 'required|string',
+                'exam_data_list.*.student_id' => 'required|string',
+                'exam_data_list.*.questions' => 'required|array',
+            ]);
+
+            if ($validator->fails()) {
+                return response()->json([
+                    'success' => false,
+                    'error' => 'Validation failed',
+                    'details' => $validator->errors()
+                ], 422);
+            }
+
+            $examDataList = $request->input('exam_data_list');
+            $results = [];
+
+            foreach ($examDataList as $examData) {
+                try {
+                    $result = $this->analysisService->analyzeExamAnswers($examData);
+                    $results[] = [
+                        'student_id' => $examData['student_id'],
+                        'exam_id' => $examData['exam_id'],
+                        'success' => true,
+                        'data' => $result
+                    ];
+                } catch (\Exception $e) {
+                    $results[] = [
+                        'student_id' => $examData['student_id'],
+                        'exam_id' => $examData['exam_id'],
+                        'success' => false,
+                        'error' => $e->getMessage()
+                    ];
+                }
+            }
+
+            $successCount = count(array_filter($results, fn($r) => $r['success']));
+
+            return response()->json([
+                'success' => true,
+                'data' => $results,
+                'summary' => [
+                    'total' => count($results),
+                    'success' => $successCount,
+                    'failed' => count($results) - $successCount
+                ],
+                'message' => "批量分析完成,成功 {$successCount} 条,失败 " . (count($results) - $successCount) . " 条"
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('批量分析失败', [
+                'error' => $e->getMessage(),
+                'request_data' => $request->all()
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'error' => '批量分析失败:' . $e->getMessage()
+            ], 500);
+        }
+    }
+}

+ 2 - 2
app/Http/Controllers/ImportStreamController.php

@@ -4,12 +4,12 @@ namespace App\Http\Controllers;
 
 use Filament\Facades\Filament;
 use Illuminate\Http\Request;
-use Illuminate\Http\StreamedResponse;
+use Illuminate\Http\StreamedResponse as LaravelStreamedResponse;
 use Illuminate\Support\Facades\Redis;
 
 class ImportStreamController extends Controller
 {
-    public function stream(Request $request): StreamedResponse
+    public function stream(Request $request): LaravelStreamedResponse
     {
         if (!Filament::auth()->check()) {
             abort(403);

+ 72 - 12
app/Jobs/ProcessMarkdownCandidateBatch.php

@@ -5,6 +5,7 @@ namespace App\Jobs;
 use App\Models\MarkdownImport;
 use App\Models\PreQuestionCandidate;
 use App\Services\MarkdownQuestionParser;
+use App\Services\PdfStorageService;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -28,7 +29,7 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
         //
     }
 
-    public function handle(MarkdownQuestionParser $parser): void
+    public function handle(MarkdownQuestionParser $parser, PdfStorageService $uploader): void
     {
         $import = MarkdownImport::find($this->markdownImportId);
         if (!$import) {
@@ -57,22 +58,33 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
 
         foreach ($records as $record) {
             try {
+                $meta = $record->meta ?? [];
+                if (!empty($meta['ai_parsed'])) {
+                    $processed++;
+                    continue;
+                }
+
                 $existingConfidence = $record->confidence ?? $record->ai_confidence;
 
                 // 置信度高(>=0.85)时跳过再次 AI 解析,直接计入进度
                 if ($existingConfidence !== null && (float) $existingConfidence >= 0.85) {
+                    $this->markParsed($record);
                     $processed++;
                     continue;
                 }
 
                 // 快速过滤卷子/区块标题,避免误判为题目再次走 AI
                 if (!$this->isLikelyQuestion((string) $record->raw_markdown)) {
+                    $meta['ai_parsed'] = true;
+                    $meta['ai_parsed_at'] = now()->toDateTimeString();
+
                     $record->update([
                         'is_question_candidate' => false,
                         'ai_confidence' => 0.0,
                         'confidence' => 0.0,
                         'is_valid_question' => false,
                         'status' => 'rejected',
+                        'meta' => $meta,
                     ]);
                     $processed++;
                     continue;
@@ -80,19 +92,35 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
 
                 // 已经处理过的不重复处理
                 if (in_array($record->status, ['pending', 'reviewed', 'accepted', 'rejected'], true) && $record->stem !== null) {
+                    $this->markParsed($record);
                     continue;
                 }
 
                 $parsed = $parser->parseRawMarkdown((string) $record->raw_markdown, (int) $record->index);
 
+                $meta = $record->meta ?? [];
+                $meta['ai_parsed'] = true;
+                $meta['ai_parsed_at'] = now()->toDateTimeString();
+
+                // 结构化后立即回传图片
+                $uploadedImages = [];
+                if (!empty($parsed['images'])) {
+                    foreach ($parsed['images'] as $idx => $imgUrl) {
+                        $path = "imports/images/{$record->id}_{$idx}.jpg";
+                        $uploadedImages[] = $uploader->put($path, (string)@file_get_contents($imgUrl)) ?: $imgUrl;
+                    }
+                }
+                $meta['images_uploaded'] = !empty($uploadedImages);
+
                 $record->update([
                     'stem' => $parsed['stem'] ?? null,
                     'options' => $parsed['options'] ?? null,
-                    'images' => $parsed['images'] ?? [],
+                    'images' => !empty($uploadedImages) ? $uploadedImages : $parsedImages,
                     'tables' => $parsed['tables'] ?? [],
                     'is_question_candidate' => (bool) ($parsed['is_question_candidate'] ?? false),
                     'ai_confidence' => $parsed['ai_confidence'] ?? null,
                     'status' => 'pending',
+                    'meta' => $meta,
                 ]);
 
                 $processed++;
@@ -109,16 +137,7 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
             }
         }
 
-        if ($processed > 0) {
-            DB::table('markdown_imports')
-                ->where('id', $this->markdownImportId)
-                ->update([
-                    'progress_current' => DB::raw('progress_current + ' . (int) $processed),
-                    'progress_updated_at' => now(),
-                    'progress_stage' => MarkdownImport::STAGE_AI_PARSING,
-                    'progress_message' => 'AI 解析中…',
-                ]);
-        }
+        $this->refreshProgress();
 
         Log::info('Markdown batch finished', [
             'import_id' => $this->markdownImportId,
@@ -166,6 +185,47 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
         }
     }
 
+    private function refreshProgress(): void
+    {
+        $total = PreQuestionCandidate::query()
+            ->where('import_id', $this->markdownImportId)
+            ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
+            ->count();
+
+        $parsed = PreQuestionCandidate::query()
+            ->where('import_id', $this->markdownImportId)
+            ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
+            ->where(function ($query) {
+                $query->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.ai_parsed')) = 'true'")
+                    ->orWhereNotNull('stem')
+                    ->orWhereNotNull('ai_confidence')
+                    ->orWhereNotNull('confidence');
+            })
+            ->count();
+
+        DB::table('markdown_imports')
+            ->where('id', $this->markdownImportId)
+            ->update([
+                'progress_total' => $total,
+                'progress_current' => min($parsed, $total),
+                'progress_updated_at' => now(),
+                'progress_stage' => MarkdownImport::STAGE_AI_PARSING,
+                'progress_message' => 'AI 解析中…',
+            ]);
+    }
+
+    private function markParsed(PreQuestionCandidate $record): void
+    {
+        $meta = $record->meta ?? [];
+        if (!empty($meta['ai_parsed'])) {
+            return;
+        }
+
+        $meta['ai_parsed'] = true;
+        $meta['ai_parsed_at'] = now()->toDateTimeString();
+        $record->update(['meta' => $meta]);
+    }
+
     /**
      * 轻量启发式判断是否像一道题目,过滤卷子/部分标题和说明文字。
      */

+ 23 - 0
app/Jobs/ProcessMarkdownSplit.php

@@ -8,6 +8,7 @@ use App\Services\SourceFileParserService;
 use App\Services\SourcePaperExtractorService;
 use App\Services\PaperPartExtractorService;
 use App\Services\QuestionExtractorService;
+use App\Services\PdfStorageService;
 use App\Jobs\ProcessMarkdownCandidateBatch;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -80,6 +81,22 @@ class ProcessMarkdownSplit implements ShouldQueue
                 null
             );
 
+            // 同步上传原始 Markdown 到春笋云
+            try {
+                $storageService = app(PdfStorageService::class);
+                $path = "imports/markdown/{$markdownImport->id}_" . ($markdownImport->file_name ?: 'import.md');
+                $remoteUrl = $storageService->put($path, (string)$markdownImport->original_markdown);
+                
+                if ($remoteUrl) {
+                    $markdownImport->update(['remote_url' => $remoteUrl]);
+                }
+            } catch (\Exception $e) {
+                Log::warning('Failed to upload markdown to Chunsun', [
+                    'id' => $this->markdownImportId,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+
             // 拆分卷子和区块
             $papers = $paperExtractor->extract($sourceFile);
             $parts = collect();
@@ -94,6 +111,12 @@ class ProcessMarkdownSplit implements ShouldQueue
                 'progress_updated_at' => now(),
             ]);
 
+            // 清理旧的队列任务,避免重复批次累积
+            DB::table('jobs')
+                ->where('payload', 'like', '%\"markdownImportId\":' . $this->markdownImportId . '%')
+                ->orWhere('payload', 'like', '%\"markdownImportId\";i:' . $this->markdownImportId . ';%')
+                ->delete();
+
             PreQuestionCandidate::where('import_id', $this->markdownImportId)->update([
                 'status' => 'superseded',
             ]);

+ 75 - 0
app/Jobs/PromoteSourcePapersJob.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\SourcePaper;
+use App\Services\QuestionCandidateToQuestionService;
+use App\Services\TaskManager;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+
+class PromoteSourcePapersJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    public function __construct(
+        public readonly string $taskId,
+        public readonly array $paperIds
+    ) {}
+
+    public function handle(TaskManager $taskManager, QuestionCandidateToQuestionService $service): void
+    {
+        $paperIds = array_values(array_filter(array_unique(array_map('intval', $this->paperIds))));
+        if (empty($paperIds)) {
+            $taskManager->markTaskFailed($this->taskId, '未选择任何卷子');
+            return;
+        }
+
+        $papers = SourcePaper::query()->whereIn('id', $paperIds)->get();
+        if ($papers->isEmpty()) {
+            $taskManager->markTaskFailed($this->taskId, '未找到可入库的卷子');
+            return;
+        }
+
+        $summary = [
+            'processed' => 0,
+            'skipped' => 0,
+            'errors' => 0,
+        ];
+
+        $total = $papers->count();
+        $taskManager->updateTaskProgress($this->taskId, 5, '开始入库');
+
+        foreach ($papers as $index => $paper) {
+            $taskManager->updateTaskProgress(
+                $this->taskId,
+                (int) (5 + (($index / max(1, $total)) * 80)),
+                '正在处理:' . ($paper->title ?: ('卷子 #' . $paper->id))
+            );
+
+            $result = $service->promoteFromSourcePapers(collect([$paper]));
+            $summary['processed'] += $result['processed'];
+            $summary['skipped'] += $result['skipped'];
+            $summary['errors'] += $result['errors'];
+        }
+
+        $taskManager->markTaskCompleted($this->taskId, [
+            'processed' => $summary['processed'],
+            'skipped' => $summary['skipped'],
+            'errors' => $summary['errors'],
+        ]);
+
+        Log::info('PromoteSourcePapersJob completed', [
+            'task_id' => $this->taskId,
+            'paper_ids' => $paperIds,
+            'summary' => $summary,
+        ]);
+    }
+}

+ 13 - 4
app/Models/MarkdownImport.php

@@ -5,6 +5,7 @@ namespace App\Models;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use App\Models\PreQuestionCandidate;
 
 class MarkdownImport extends Model
 {
@@ -12,6 +13,7 @@ class MarkdownImport extends Model
 
     protected $fillable = [
         'file_name',
+        'remote_url',
         'original_markdown',
         'parsed_json',
         'source_type',
@@ -86,11 +88,13 @@ class MarkdownImport extends Model
         };
 
         if (($this->progress_total ?? 0) > 0) {
+            $total = (int) $this->progress_total;
+            $current = min((int) ($this->progress_current ?? 0), $total);
             return sprintf(
                 '%s %d/%d',
                 $stageLabel,
-                (int) ($this->progress_current ?? 0),
-                (int) $this->progress_total
+                $current,
+                $total
             );
         }
 
@@ -114,7 +118,9 @@ class MarkdownImport extends Model
             return (int) $this->attributes['parsed_count'];
         }
 
-        return $this->candidates()->count();
+        return $this->candidates()
+            ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
+            ->count();
     }
 
     public function getAcceptedCountAttribute(): int
@@ -123,7 +129,10 @@ class MarkdownImport extends Model
             return (int) $this->attributes['accepted_count'];
         }
 
-        return $this->candidates()->where('is_question_candidate', true)->count();
+        return $this->candidates()
+            ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
+            ->where('is_question_candidate', true)
+            ->count();
     }
 
     /**

+ 2 - 0
app/Models/PreQuestionCandidate.php

@@ -24,8 +24,10 @@ class PreQuestionCandidate extends Model
         'question_number',
         'order',
         'raw_markdown',
+        'raw_hash',
         'raw_text',
         'clean_markdown',
+        'clean_hash',
         'structured_json',
         'stem',
         'options',

+ 6 - 1
app/Models/SourcePaper.php

@@ -26,7 +26,7 @@ class SourcePaper extends Model
         'grade',
         'term',
         'edition',
-        'textbook_series',
+        'series_id',
         'textbook_id',
         'source_type',
         'source_year',
@@ -73,4 +73,9 @@ class SourcePaper extends Model
     {
         return $this->hasMany(PaperQuestionRef::class, 'source_paper_id');
     }
+
+    public function series(): BelongsTo
+    {
+        return $this->belongsTo(TextbookSeries::class, 'series_id');
+    }
 }

+ 3 - 12
app/Models/Textbook.php

@@ -69,21 +69,12 @@ class Textbook extends Model
         return $this->hasMany(TextbookCatalog::class);
     }
 
-    /**
-     * 获取系列信息(兼容 API 返回的嵌套对象)
-     */
-    public function getSeriesAttribute($value)
-    {
-        if (is_array($value)) {
-            return (object) $value;
-        }
-        return $value;
-    }
 
     public function getSeriesNameAttribute(): string
     {
-        if ($this->relationLoaded('series') && $this->series) {
-            return $this->series->name ?? '未归类系列';
+        $series = $this->series()->first();
+        if ($series) {
+            return $series->name ?? '未归类系列';
         }
 
         $seriesId = $this->series_id;

+ 2 - 2
app/Services/AiKnowledgeService.php

@@ -30,13 +30,13 @@ class AiKnowledgeService
         return $matched;
     }
 
-    public function matchKnowledgePointsByAi(string $questionText, array $candidates = []): array
+    public function matchKnowledgePointsByAi(string $questionText, array $candidates = [], ?string $context = null): array
     {
         if (empty($candidates)) {
             $candidates = $this->getCandidateKnowledgePoints();
         }
 
-        $prompt = app(QuestionPromptService::class)->buildKnowledgeMatchPrompt($questionText, $candidates);
+        $prompt = app(QuestionPromptService::class)->buildKnowledgeMatchPrompt($questionText, $candidates, $context);
 
         try {
             $result = app(AiClientService::class)->callJson($prompt);

+ 1051 - 0
app/Services/ApiDocumentation.php

@@ -0,0 +1,1051 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * API 文档配置服务
+ * 集中管理所有 API 的文档信息,包括参数说明、响应示例等
+ */
+class ApiDocumentation
+{
+    /**
+     * 获取所有 API 文档
+     */
+    public static function all(): array
+    {
+        return [
+            // ==================== 题库核心 API ====================
+            'api/questions' => [
+                'GET' => [
+                    'summary' => '获取题目列表',
+                    'description' => '分页获取题库中的题目,支持按知识点、难度等条件筛选',
+                    'params' => [
+                        'query' => [
+                            ['name' => 'page', 'type' => 'integer', 'required' => false, 'default' => '1', 'description' => '页码'],
+                            ['name' => 'per_page', 'type' => 'integer', 'required' => false, 'default' => '25', 'description' => '每页数量'],
+                            ['name' => 'kp_code', 'type' => 'string', 'required' => false, 'description' => '知识点编码,用于筛选特定知识点的题目'],
+                            ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度等级:easy/medium/hard'],
+                            ['name' => 'search', 'type' => 'string', 'required' => false, 'description' => '搜索关键词'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'data' => [
+                                ['id' => 1, 'content' => '计算 2+3=?', 'answer' => '5', 'difficulty' => 'easy', 'kp_code' => 'MATH_ADD_001'],
+                            ],
+                            'meta' => [
+                                'page' => 1,
+                                'per_page' => 25,
+                                'total' => 100,
+                                'total_pages' => 4,
+                            ],
+                        ],
+                        'error' => [
+                            'error' => '错误信息',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/questions/{id}' => [
+                'GET' => [
+                    'summary' => '获取单个题目详情',
+                    'description' => '根据题目 ID 获取题目的完整信息',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'id', 'type' => 'integer', 'required' => true, 'description' => '题目 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'id' => 1,
+                            'content' => '计算 2+3=?',
+                            'answer' => '5',
+                            'analysis' => '这是一道简单的加法题...',
+                            'difficulty' => 'easy',
+                            'kp_code' => 'MATH_ADD_001',
+                            'kp_name' => '整数加法',
+                            'created_at' => '2024-01-01T00:00:00Z',
+                        ],
+                        'error' => ['error' => 'Question not found'],
+                    ],
+                ],
+                'DELETE' => [
+                    'summary' => '删除题目',
+                    'description' => '根据 ID 删除指定题目',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'id', 'type' => 'integer', 'required' => true, 'description' => '题目 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => ['success' => true, 'message' => 'Question deleted'],
+                        'error' => ['success' => false, 'message' => 'Failed to delete'],
+                    ],
+                ],
+            ],
+
+            'api/questions/search' => [
+                'GET' => [
+                    'summary' => '搜索题目(简单)',
+                    'description' => '通过关键词搜索题目',
+                    'params' => [
+                        'query' => [
+                            ['name' => 'q', 'type' => 'string', 'required' => true, 'description' => '搜索关键词'],
+                            ['name' => 'limit', 'type' => 'integer', 'required' => false, 'default' => '20', 'description' => '返回数量限制'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'data' => [
+                                ['id' => 1, 'content' => '...', 'score' => 0.95],
+                            ],
+                            'total' => 10,
+                        ],
+                    ],
+                ],
+                'POST' => [
+                    'summary' => '语义搜索题目',
+                    'description' => '使用 AI 语义搜索匹配题目',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'query', 'type' => 'string', 'required' => true, 'description' => '搜索查询文本'],
+                            ['name' => 'limit', 'type' => 'integer', 'required' => false, 'default' => '20', 'description' => '返回数量限制'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'data' => [
+                                ['id' => 1, 'content' => '...', 'similarity' => 0.92],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/questions/random' => [
+                'GET' => [
+                    'summary' => '随机获取题目',
+                    'description' => '随机获取指定数量的题目,可按条件筛选',
+                    'params' => [
+                        'query' => [
+                            ['name' => 'count', 'type' => 'integer', 'required' => false, 'default' => '10', 'description' => '获取数量'],
+                            ['name' => 'kp_code', 'type' => 'string', 'required' => false, 'description' => '知识点编码'],
+                            ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度等级'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'data' => [
+                                ['id' => 5, 'content' => '...'],
+                            ],
+                            'count' => 10,
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/questions/statistics' => [
+                'GET' => [
+                    'summary' => '获取题目统计信息',
+                    'description' => '获取题库的统计数据,包括总数、各难度分布等',
+                    'params' => [],
+                    'response' => [
+                        'success' => [
+                            'total' => 1000,
+                            'by_difficulty' => [
+                                'easy' => 300,
+                                'medium' => 500,
+                                'hard' => 200,
+                            ],
+                            'by_knowledge_point' => [
+                                'MATH_ADD' => 150,
+                                'MATH_SUB' => 120,
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/questions/generate' => [
+                'POST' => [
+                    'summary' => 'AI 生成题目',
+                    'description' => '使用 AI 根据知识点和关键词生成新题目(异步任务)',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'kp_code', 'type' => 'string', 'required' => true, 'description' => '知识点编码'],
+                            ['name' => 'keyword', 'type' => 'string', 'required' => false, 'description' => '生成关键词/提示'],
+                            ['name' => 'count', 'type' => 'integer', 'required' => false, 'default' => '5', 'description' => '生成数量'],
+                            ['name' => 'strategy', 'type' => 'string', 'required' => false, 'description' => '生成策略'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'task_id' => 'task_abc123',
+                            'message' => 'Generation task started',
+                        ],
+                        'error' => [
+                            'success' => false,
+                            'message' => '生成失败原因',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/questions/{id}/solution' => [
+                'GET' => [
+                    'summary' => '获取题目解析',
+                    'description' => '获取指定题目的详细解析和解题步骤',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'id', 'type' => 'integer', 'required' => true, 'description' => '题目 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'id' => 1,
+                            'solution' => '解题步骤...',
+                            'key_points' => ['加法运算', '进位'],
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== 试卷 API ====================
+            'api/papers/assemble' => [
+                'POST' => [
+                    'summary' => '组装试卷',
+                    'description' => '根据题目 ID 列表组装生成试卷',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'question_ids', 'type' => 'array', 'required' => true, 'description' => '题目 ID 数组'],
+                            ['name' => 'title', 'type' => 'string', 'required' => false, 'description' => '试卷标题'],
+                            ['name' => 'description', 'type' => 'string', 'required' => false, 'description' => '试卷描述'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'paper_id' => 'paper_abc123',
+                            'title' => '期中测试卷',
+                            'question_count' => 20,
+                            'created_at' => '2024-01-01T00:00:00Z',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/papers/{paperId}/json' => [
+                'GET' => [
+                    'summary' => '获取试卷 JSON',
+                    'description' => '返回与智能出卷返回值中 exam_content 完全一致的试卷 JSON,可预览或下载',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'paperId', 'type' => 'string', 'required' => true, 'description' => '试卷 ID'],
+                        ],
+                        'query' => [
+                            ['name' => 'download', 'type' => 'integer', 'required' => false, 'description' => '设为 1 时下载 JSON 文件'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'paper_id' => 'paper_abc123',
+                            'title' => '期中测试卷',
+                            'questions' => [
+                                ['id' => 1, 'content' => '...', 'answer' => '...'],
+                            ],
+                        ],
+                    ],
+                    'examples' => [
+                        'GET /api/papers/paper_1765788931_ce02f6a3/json',
+                        'GET /api/papers/paper_1765788931_ce02f6a3/json?download=1',
+                    ],
+                ],
+            ],
+
+            // ==================== 智能出卷与学情报告 ====================
+            'api/intelligent-exams' => [
+                'POST' => [
+                    'summary' => '智能出卷',
+                    'description' => '根据学生情况智能生成个性化试卷,返回 PDF 和判卷地址',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'knowledge_points', 'type' => 'array', 'required' => false, 'description' => '指定知识点列表'],
+                            ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度偏好:easy/medium/hard/adaptive'],
+                            ['name' => 'question_count', 'type' => 'integer', 'required' => false, 'default' => '20', 'description' => '题目数量'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'task_id' => 'exam_task_abc123',
+                            'status' => 'processing',
+                            'message' => '试卷生成任务已提交',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/intelligent-exams/status/{taskId}' => [
+                'GET' => [
+                    'summary' => '查询出卷任务状态',
+                    'description' => '查询智能出卷任务的处理状态',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'taskId', 'type' => 'string', 'required' => true, 'description' => '任务 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'task_id' => 'exam_task_abc123',
+                            'status' => 'completed',
+                            'pdf_url' => '/storage/exams/exam_abc123.pdf',
+                            'exam_content' => ['...'],
+                        ],
+                        'pending' => [
+                            'task_id' => 'exam_task_abc123',
+                            'status' => 'processing',
+                            'progress' => 60,
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/exam-analysis/report' => [
+                'POST' => [
+                    'summary' => '生成学情报告',
+                    'description' => '根据学生答题数据生成学情分析报告 PDF',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'exam_id', 'type' => 'string', 'required' => false, 'description' => '指定考试 ID'],
+                            ['name' => 'date_range', 'type' => 'object', 'required' => false, 'description' => '日期范围 {start, end}'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'task_id' => 'report_task_abc123',
+                            'status' => 'processing',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/exam-analysis/status/{taskId}' => [
+                'GET' => [
+                    'summary' => '查询学情报告任务状态',
+                    'description' => '查询学情报告生成任务的处理状态',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'taskId', 'type' => 'string', 'required' => true, 'description' => '任务 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'task_id' => 'report_task_abc123',
+                            'status' => 'completed',
+                            'pdf_url' => '/storage/reports/report_abc123.pdf',
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== 错题本 API ====================
+            'api/mistake-book' => [
+                'GET' => [
+                    'summary' => '获取错题列表',
+                    'description' => '获取学生的错题记录列表,支持分页和筛选',
+                    'params' => [
+                        'query' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'page', 'type' => 'integer', 'required' => false, 'default' => '1', 'description' => '页码'],
+                            ['name' => 'per_page', 'type' => 'integer', 'required' => false, 'default' => '20', 'description' => '每页数量'],
+                            ['name' => 'kp_code', 'type' => 'string', 'required' => false, 'description' => '按知识点筛选'],
+                            ['name' => 'error_type', 'type' => 'string', 'required' => false, 'description' => '错误类型筛选'],
+                            ['name' => 'is_favorite', 'type' => 'boolean', 'required' => false, 'description' => '只看收藏'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'data' => [
+                                [
+                                    'id' => 1,
+                                    'question_id' => 100,
+                                    'question_content' => '...',
+                                    'student_answer' => '...',
+                                    'correct_answer' => '...',
+                                    'error_type' => 'calculation',
+                                    'is_favorite' => false,
+                                    'review_count' => 2,
+                                    'created_at' => '2024-01-01T00:00:00Z',
+                                ],
+                            ],
+                            'meta' => ['page' => 1, 'total' => 50],
+                        ],
+                    ],
+                ],
+                'POST' => [
+                    'summary' => '新增错题',
+                    'description' => '添加新的错题记录',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'question_id', 'type' => 'integer', 'required' => true, 'description' => '题目 ID'],
+                            ['name' => 'student_answer', 'type' => 'string', 'required' => true, 'description' => '学生答案'],
+                            ['name' => 'error_type', 'type' => 'string', 'required' => false, 'description' => '错误类型'],
+                            ['name' => 'notes', 'type' => 'string', 'required' => false, 'description' => '备注'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'id' => 1,
+                            'message' => '错题已添加',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mistake-book/{mistakeId}' => [
+                'GET' => [
+                    'summary' => '获取错题详情',
+                    'description' => '获取单条错题的详细信息',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'mistakeId', 'type' => 'integer', 'required' => true, 'description' => '错题记录 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'id' => 1,
+                            'question' => ['id' => 100, 'content' => '...', 'answer' => '...'],
+                            'student_answer' => '...',
+                            'error_analysis' => '...',
+                            'review_history' => [],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mistake-book/{mistakeId}/favorite' => [
+                'POST' => [
+                    'summary' => '收藏/取消收藏错题',
+                    'description' => '切换错题的收藏状态',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'mistakeId', 'type' => 'integer', 'required' => true, 'description' => '错题记录 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'is_favorite' => true,
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mistake-book/{mistakeId}/review' => [
+                'POST' => [
+                    'summary' => '标记错题已复习',
+                    'description' => '将错题标记为已复习状态',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'mistakeId', 'type' => 'integer', 'required' => true, 'description' => '错题记录 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'review_count' => 3,
+                            'last_review_at' => '2024-01-01T00:00:00Z',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mistake-book/snapshot' => [
+                'GET' => [
+                    'summary' => '获取错题本快照',
+                    'description' => '获取错题本的统计快照数据,用于仪表板展示',
+                    'params' => [
+                        'query' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'total_mistakes' => 50,
+                            'reviewed_count' => 30,
+                            'favorite_count' => 10,
+                            'by_knowledge_point' => [
+                                ['kp_code' => 'MATH_ADD', 'count' => 15],
+                            ],
+                            'recent_mistakes' => [],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mistake-book/batch-operation' => [
+                'POST' => [
+                    'summary' => '错题批量操作',
+                    'description' => '对多条错题进行批量操作',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'mistake_ids', 'type' => 'array', 'required' => true, 'description' => '错题 ID 数组'],
+                            ['name' => 'operation', 'type' => 'string', 'required' => true, 'description' => '操作类型:mark_reviewed/mark_mastered/favorite/unfavorite'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'affected_count' => 5,
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== 知识点掌握 API ====================
+            'api/knowledge-mastery/stats/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取知识点掌握统计',
+                    'description' => '获取学生各知识点的掌握情况统计数据',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'student_id' => '1764913638',
+                            'total_knowledge_points' => 50,
+                            'average_mastery' => 0.75,
+                            'mastered_count' => 25,
+                            'good_count' => 15,
+                            'weak_count' => 10,
+                            'details' => [
+                                ['kp_code' => 'MATH_ADD', 'kp_name' => '加法运算', 'mastery_level' => 0.85, 'mastery_change' => 0.05],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/knowledge-mastery/summary/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取知识点掌握摘要',
+                    'description' => '获取学生知识点掌握情况的简化摘要信息',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'student_id' => '1764913638',
+                            'total' => 50,
+                            'mastered' => 25,
+                            'unmastered' => 25,
+                            'mastery_rate' => 0.5,
+                            'mastery_percentage' => '50%',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/knowledge-mastery/graph/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取知识点图谱数据',
+                    'description' => '获取学生的知识点掌握图谱可视化数据',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                        'query' => [
+                            ['name' => 'exam_id', 'type' => 'string', 'required' => false, 'description' => '考试 ID(可选)'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'nodes' => [
+                                ['id' => 'MATH_ADD', 'name' => '加法运算', 'mastery' => 0.85, 'category' => '计算'],
+                            ],
+                            'edges' => [
+                                ['source' => 'MATH_ADD', 'target' => 'MATH_SUB'],
+                            ],
+                            'statistics' => [
+                                'total_nodes' => 50,
+                                'total_edges' => 45,
+                                'average_mastery' => 0.75,
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/knowledge-mastery/graph/snapshots/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取知识点图谱快照列表',
+                    'description' => '获取学生知识点图谱的历史快照列表',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                        'query' => [
+                            ['name' => 'limit', 'type' => 'integer', 'required' => false, 'default' => '10', 'description' => '返回数量限制'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'snapshots' => [
+                                [
+                                    'snapshot_id' => 'snap_001',
+                                    'overall_mastery' => 0.75,
+                                    'weak_count' => 10,
+                                    'strong_count' => 25,
+                                    'created_at' => '2024-01-01T00:00:00Z',
+                                ],
+                            ],
+                            'total' => 5,
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/knowledge-mastery/snapshots/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取知识点快照列表(简化路径)',
+                    'description' => '获取学生知识点掌握情况的历史快照列表(简化路径)',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                        'query' => [
+                            ['name' => 'limit', 'type' => 'integer', 'required' => false, 'default' => '10', 'description' => '返回数量限制'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'snapshots' => [
+                                [
+                                    'snapshot_id' => 'snap_001',
+                                    'overall_mastery' => 0.75,
+                                    'weak_count' => 10,
+                                    'strong_count' => 25,
+                                    'created_at' => '2024-01-01T00:00:00Z',
+                                ],
+                            ],
+                            'total' => 5,
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/knowledge-mastery/snapshot/{studentId}' => [
+                'POST' => [
+                    'summary' => '创建知识点掌握度快照',
+                    'description' => '为学生当前知识点掌握情况创建快照记录',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                        'body' => [
+                            ['name' => 'snapshot_type', 'type' => 'string', 'required' => false, 'default' => 'report', 'description' => '快照类型'],
+                            ['name' => 'source_id', 'type' => 'string', 'required' => false, 'description' => '来源 ID'],
+                            ['name' => 'source_name', 'type' => 'string', 'required' => false, 'description' => '来源名称'],
+                            ['name' => 'notes', 'type' => 'string', 'required' => false, 'description' => '备注信息'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'message' => '快照创建成功',
+                            'data' => [
+                                'snapshot_id' => 'snap_new_001',
+                                'student_id' => '1764913638',
+                                'created_at' => '2024-01-01T00:00:00Z',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== 教材 API ====================
+            'api/textbooks' => [
+                'GET' => [
+                    'summary' => '获取教材列表',
+                    'description' => '获取所有教材列表,按年级排序',
+                    'params' => [],
+                    'response' => [
+                        'success' => [
+                            'data' => [
+                                ['id' => 1, 'name' => '七年级上册', 'grade' => 7, 'semester' => 1],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/textbooks/{id}' => [
+                'GET' => [
+                    'summary' => '获取教材详情',
+                    'description' => '获取单个教材的详细信息',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'id', 'type' => 'integer', 'required' => true, 'description' => '教材 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'id' => 1,
+                            'name' => '七年级上册',
+                            'grade' => 7,
+                            'chapters' => [],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/textbooks/{id}/catalog' => [
+                'GET' => [
+                    'summary' => '获取教材目录',
+                    'description' => '获取教材的章节目录结构',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'id', 'type' => 'integer', 'required' => true, 'description' => '教材 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'chapters' => [
+                                [
+                                    'id' => 1,
+                                    'title' => '第一章 有理数',
+                                    'sections' => [
+                                        ['id' => 11, 'title' => '1.1 正数和负数'],
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== 知识点与能力 API ====================
+            'api/knowledge-points' => [
+                'GET' => [
+                    'summary' => '获取知识点列表',
+                    'description' => '获取所有知识点选项,用于下拉选择',
+                    'params' => [],
+                    'response' => [
+                        'success' => [
+                            ['code' => 'MATH_ADD', 'name' => '加法运算'],
+                            ['code' => 'MATH_SUB', 'name' => '减法运算'],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/knowledge/recommend' => [
+                'GET' => [
+                    'summary' => '知识点推荐',
+                    'description' => '根据学生情况推荐需要加强的知识点',
+                    'params' => [
+                        'query' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'limit', 'type' => 'integer', 'required' => false, 'default' => '5', 'description' => '推荐数量'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'recommendations' => [
+                                ['kp_code' => 'MATH_FRAC', 'kp_name' => '分数运算', 'reason' => '错题率较高', 'priority' => 1],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/abilities/evaluate' => [
+                'POST' => [
+                    'summary' => '能力评估',
+                    'description' => '评估学生的数学能力水平',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'answers', 'type' => 'array', 'required' => true, 'description' => '答题记录数组'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'student_id' => 1,
+                            'abilities' => [
+                                ['name' => '计算能力', 'score' => 85],
+                                ['name' => '逻辑推理', 'score' => 78],
+                            ],
+                            'overall_score' => 80,
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== MathRecSys 集成 API ====================
+            'api/mathrecsys/health' => [
+                'GET' => [
+                    'summary' => 'MathRecSys 健康检查',
+                    'description' => '检查 MathRecSys 服务的健康状态',
+                    'params' => [],
+                    'response' => [
+                        'success' => [
+                            'status' => 'healthy',
+                            'service' => 'MathRecSys',
+                            'timestamp' => '2024-01-01T00:00:00Z',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mathrecsys/students/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取学生完整信息',
+                    'description' => '从 MathRecSys 获取学生的完整学习信息',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'id' => 1,
+                            'name' => '张三',
+                            'grade' => 7,
+                            'learning_profile' => [],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mathrecsys/students/{studentId}/recommendations' => [
+                'GET' => [
+                    'summary' => '获取个性化推荐',
+                    'description' => '获取基于学生学习情况的个性化题目推荐',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                        'query' => [
+                            ['name' => 'count', 'type' => 'integer', 'required' => false, 'default' => '10', 'description' => '推荐数量'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'recommendations' => [
+                                ['question_id' => 100, 'reason' => '加强分数运算', 'priority' => 1],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mathrecsys/students/{studentId}/trajectory' => [
+                'GET' => [
+                    'summary' => '获取学习轨迹',
+                    'description' => '获取学生的学习轨迹数据',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'integer', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'trajectory' => [
+                                ['date' => '2024-01-01', 'activity' => 'practice', 'duration' => 30],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/mathrecsys/classes/{classId}/analysis' => [
+                'GET' => [
+                    'summary' => '班级分析',
+                    'description' => '获取班级整体学习分析数据',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'classId', 'type' => 'integer', 'required' => true, 'description' => '班级 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'class_id' => 1,
+                            'student_count' => 40,
+                            'average_mastery' => 0.75,
+                            'weak_points' => [],
+                        ],
+                    ],
+                ],
+            ],
+
+            // ==================== 学生作答分析 API ====================
+            'api/student-answers/analyze' => [
+                'POST' => [
+                    'summary' => '提交学生作答结果',
+                    'description' => '接收外部系统发送的学生作答结果,进行AI分析并更新掌握度',
+                    'params' => [
+                        'body' => [
+                            ['name' => 'paper_id', 'type' => 'string', 'required' => true, 'description' => '试卷 ID'],
+                            ['name' => 'student_id', 'type' => 'string', 'required' => true, 'description' => '学生 ID'],
+                            ['name' => 'answers', 'type' => 'array', 'required' => true, 'description' => '作答结果数组'],
+                            ['name' => 'answers.*.question_id', 'type' => 'string', 'required' => true, 'description' => '题目 ID'],
+                            ['name' => 'answers.*.is_correct', 'type' => 'boolean', 'required' => true, 'description' => '是否正确'],
+                            ['name' => 'answers.*.student_answer', 'type' => 'string', 'required' => false, 'description' => '学生答案'],
+                            ['name' => 'answers.*.correct_answer', 'type' => 'string', 'required' => false, 'description' => '正确答案'],
+                            ['name' => 'answers.*.score', 'type' => 'number', 'required' => false, 'description' => '得分'],
+                            ['name' => 'answers.*.max_score', 'type' => 'number', 'required' => false, 'description' => '满分'],
+                            ['name' => 'answers.*.step_scores', 'type' => 'array', 'required' => false, 'description' => '简答题步骤得分'],
+                            ['name' => 'answers.*.knowledge_point', 'type' => 'string', 'required' => false, 'description' => '知识点编码'],
+                            ['name' => 'answer_time', 'type' => 'timestamp', 'required' => false, 'description' => '答题时间'],
+                            ['name' => 'submit_time', 'type' => 'timestamp', 'required' => false, 'description' => '提交时间'],
+                            ['name' => 'source_system', 'type' => 'string', 'required' => false, 'description' => '来源系统'],
+                            ['name' => 'callback_url', 'type' => 'url', 'required' => false, 'description' => '回调通知 URL'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'success' => true,
+                            'message' => '作答结果已提交,正在分析中...',
+                            'data' => [
+                                'task_id' => 'analysis_task_xxx',
+                                'paper_id' => 'paper_xxx',
+                                'student_id' => 'student_xxx',
+                                'status' => 'processing',
+                                'created_at' => '2024-01-01T00:00:00Z',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/student-answers/analysis/status/{taskId}' => [
+                'GET' => [
+                    'summary' => '查询分析任务状态',
+                    'description' => '查询作答分析任务的处理状态',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'taskId', 'type' => 'string', 'required' => true, 'description' => '任务 ID'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'task_id' => 'analysis_task_xxx',
+                            'type' => 'analysis',
+                            'status' => 'completed',
+                            'progress' => 100,
+                            'message' => '分析完成',
+                            'answer_record_id' => 'ans_xxx',
+                            'analysis_id' => 'analysis_xxx',
+                            'mastery_snapshot_id' => 'snap_xxx',
+                            'correct_count' => 15,
+                            'wrong_count' => 5,
+                            'overall_mastery' => 0.75,
+                            'completed_at' => '2024-01-01T00:05:00Z',
+                        ],
+                        'pending' => [
+                            'task_id' => 'analysis_task_xxx',
+                            'status' => 'processing',
+                            'progress' => 60,
+                            'message' => '正在更新掌握度...',
+                        ],
+                    ],
+                ],
+            ],
+
+            'api/student-answers/history/{studentId}' => [
+                'GET' => [
+                    'summary' => '获取学生学习历史',
+                    'description' => '获取学生的练习记录、错题记录和掌握度快照历史',
+                    'params' => [
+                        'path' => [
+                            ['name' => 'studentId', 'type' => 'string', 'required' => true, 'description' => '学生 ID'],
+                        ],
+                        'query' => [
+                            ['name' => 'limit', 'type' => 'integer', 'required' => false, 'default' => '10', 'description' => '返回记录数量限制'],
+                        ],
+                    ],
+                    'response' => [
+                        'success' => [
+                            'exercises' => [
+                                [
+                                    'student_id' => 'student_xxx',
+                                    'question_id' => 'q_xxx',
+                                    'is_correct' => true,
+                                    'kp_code' => 'MATH_ADD',
+                                    'created_at' => '2024-01-01T00:00:00Z',
+                                ],
+                            ],
+                            'mistakes' => [
+                                [
+                                    'student_id' => 'student_xxx',
+                                    'question_id' => 'q_xxx',
+                                    'error_type' => 'calculation',
+                                    'review_status' => 'pending',
+                                    'created_at' => '2024-01-01T00:00:00Z',
+                                ],
+                            ],
+                            'mastery_snapshots' => [
+                                [
+                                    'snapshot_id' => 'snap_xxx',
+                                    'overall_mastery' => 0.75,
+                                    'weak_knowledge_points_count' => 3,
+                                    'strong_knowledge_points_count' => 7,
+                                    'snapshot_time' => '2024-01-01T00:00:00Z',
+                                ],
+                            ],
+                            'summary' => [
+                                'total_exercises' => 100,
+                                'total_mistakes' => 25,
+                                'mastery_snapshots_count' => 10,
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * 获取指定 API 的文档
+     */
+    public static function get(string $uri, ?string $method = null): ?array
+    {
+        $docs = self::all();
+
+        if (!isset($docs[$uri])) {
+            return null;
+        }
+
+        if ($method) {
+            return $docs[$uri][$method] ?? null;
+        }
+
+        return $docs[$uri];
+    }
+
+    /**
+     * 获取 HTTP 方法的颜色样式
+     */
+    public static function getMethodColor(string $method): string
+    {
+        return match (strtoupper($method)) {
+            'GET' => 'bg-green-100 text-green-700 border-green-200',
+            'POST' => 'bg-blue-100 text-blue-700 border-blue-200',
+            'PUT' => 'bg-amber-100 text-amber-700 border-amber-200',
+            'PATCH' => 'bg-purple-100 text-purple-700 border-purple-200',
+            'DELETE' => 'bg-red-100 text-red-700 border-red-200',
+            default => 'bg-gray-100 text-gray-700 border-gray-200',
+        };
+    }
+}

+ 27 - 18
app/Services/AsyncMarkdownSplitter.php

@@ -15,15 +15,35 @@ class AsyncMarkdownSplitter
     public function split(string $markdown): array
     {
         // 使用正则表达式识别题号作为切分点(只接受“数字 + 明确分隔符”)
-        // 注意:不要用 “数字 + 空白” 作为切分点,会误切正文中的列表/步骤/年份等。
         $pattern = '/^\s*(\d{1,4})(?:[\\..、\\))\\]】])\\s*/m';
+        $commentPattern = '/<!--\s*question:\s*(\d+)\s*-->/i';
 
-        // 找到所有匹配的位置
+        // 统一寻找切分点
         preg_match_all($pattern, $markdown, $matches, PREG_OFFSET_CAPTURE);
+        preg_match_all($commentPattern, $markdown, $commentMatches, PREG_OFFSET_CAPTURE);
+
+        // 合并匹配结果并按偏移量排序
+        $allMatches = [];
+        foreach ($matches[1] as $idx => $m) {
+            $allMatches[] = [
+                'pos' => $matches[0][$idx][1],
+                'length' => strlen($matches[0][$idx][0]),
+                'index' => (int)$m[0],
+            ];
+        }
+        foreach ($commentMatches[1] as $idx => $m) {
+            $allMatches[] = [
+                'pos' => $commentMatches[0][$idx][1],
+                'length' => strlen($commentMatches[0][$idx][0]),
+                'index' => (int)$m[0],
+            ];
+        }
+
+        usort($allMatches, fn($a, $b) => $a['pos'] <=> $b['pos']);
 
         $candidates = [];
 
-        if (empty($matches[0])) {
+        if (empty($allMatches)) {
             // 没有找到题号,整个作为一块
             return [
                 [
@@ -33,28 +53,17 @@ class AsyncMarkdownSplitter
             ];
         }
 
-        // 构建分块
-        $positions = [];
-        foreach ($matches[0] as $match) {
-            $positions[] = $match[1];
-        }
-
-        for ($i = 0; $i < count($positions); $i++) {
-            $start = $positions[$i];
-            $end = $i + 1 < count($positions) ? $positions[$i + 1] : strlen($markdown);
+        for ($i = 0; $i < count($allMatches); $i++) {
+            $start = $allMatches[$i]['pos'];
+            $end = $i + 1 < count($allMatches) ? $allMatches[$i+1]['pos'] : strlen($markdown);
 
             $block = substr($markdown, $start, $end - $start);
             $block = trim($block);
 
             if (!empty($block)) {
-                // 提取题号作为 index
-                preg_match('/^\s*(\d+)/', $block, $indexMatch);
-                $index = $indexMatch[1] ?? ($i + 1);
-
                 $candidates[] = [
-                    // sequence:文件内顺序,保证唯一,不会因为 index 重复而覆盖
                     'sequence' => $i + 1,
-                    'index' => (int)$index,
+                    'index' => $allMatches[$i]['index'],
                     'raw_markdown' => $block
                 ];
             }

+ 94 - 1
app/Services/ExamAnalysisService.php

@@ -20,7 +20,8 @@ class ExamAnalysisService
         private readonly LearningAnalyticsService $learningAnalyticsService,
         private readonly QuestionBankService $questionBankService,
         private readonly ExamPdfExportService $pdfExportService,
-        private readonly QuestionServiceApi $questionServiceApi
+        private readonly QuestionServiceApi $questionServiceApi,
+        private readonly ExamAnswerAnalysisService $examAnswerAnalysisService
     ) {}
 
     /**
@@ -424,4 +425,96 @@ class ExamAnalysisService
             ]);
         }
     }
+
+    /**
+     * 使用步骤级分析算法分析考试答题数据
+     *
+     * 这是基于《卷子分析思考.md》思路的增强版分析方法
+     * 支持步骤级分析、知识点映射和智能出卷推荐
+     *
+     * @param array $examData 考试数据,包含题目和步骤信息
+     * @return array 分析结果
+     *
+     * 示例输入:
+     * [
+     *   'exam_id' => 'exam_001',
+     *   'student_id' => 'student_001',
+     *   'questions' => [
+     *     [
+     *       'question_id' => 'Q1',
+     *       'score' => 5,
+     *       'score_obtained' => 3,
+     *       'steps' => [
+     *         ['step_index' => 1, 'is_correct' => true, 'kp_id' => 'K-SQRT-SIMPLE'],
+     *         ['step_index' => 2, 'is_correct' => false, 'kp_id' => 'K-NUM-ADD-SUB']
+     *       ]
+     *     ]
+     *   ]
+     * ]
+     */
+    public function analyzeWithSteps(array $examData): array
+    {
+        Log::info('ExamAnalysisService: 开始步骤级分析', [
+            'exam_id' => $examData['exam_id'] ?? 'unknown',
+            'student_id' => $examData['student_id'] ?? 'unknown',
+            'question_count' => count($examData['questions'] ?? [])
+        ]);
+
+        try {
+            // 使用增强的分析算法
+            $result = $this->examAnswerAnalysisService->analyzeExamAnswers($examData);
+
+            Log::info('ExamAnalysisService: 步骤级分析完成', [
+                'exam_id' => $examData['exam_id'],
+                'student_id' => $examData['student_id'],
+                'knowledge_points_analyzed' => count($result['knowledge_point_analysis'] ?? [])
+            ]);
+
+            return $result;
+
+        } catch (\Exception $e) {
+            Log::error('ExamAnalysisService: 步骤级分析失败', [
+                'exam_id' => $examData['exam_id'] ?? 'unknown',
+                'student_id' => $examData['student_id'] ?? 'unknown',
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+
+            throw new \Exception('步骤级分析失败:' . $e->getMessage());
+        }
+    }
+
+    /**
+     * 获取步骤级分析结果
+     *
+     * @param string $studentId
+     * @param string $examId
+     * @return array|null
+     */
+    public function getStepAnalysisResult(string $studentId, string $examId): ?array
+    {
+        try {
+            $result = \DB::connection('pgsql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->where('exam_id', $examId)
+                ->orderBy('created_at', 'desc')
+                ->first();
+
+            if (!$result) {
+                return null;
+            }
+
+            return json_decode($result->analysis_data, true);
+
+        } catch (\Exception $e) {
+            Log::error('ExamAnalysisService: 获取步骤级分析结果失败', [
+                'student_id' => $studentId,
+                'exam_id' => $examId,
+                'error' => $e->getMessage()
+            ]);
+
+            return null;
+        }
+    }
 }

+ 635 - 0
app/Services/ExamAnswerAnalysisService.php

@@ -0,0 +1,635 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Collection;
+
+/**
+ * 考试答题分析服务(步骤级分析)
+ * 基于卷子分析思考文档的思路实现
+ *
+ * 核心流程:
+ * 1. 接收卷子ID和每道题的对错、简答题的分步骤对错
+ * 2. 将原子信息映射到知识点/技能
+ * 3. 计算知识点掌握度向量
+ * 4. 生成详细分析报告
+ * 5. 提供智能出卷推荐依据
+ */
+class ExamAnswerAnalysisService
+{
+    public function __construct(
+        private readonly MasteryCalculator $masteryCalculator,
+        private readonly KnowledgeMasteryService $knowledgeMasteryService,
+        private readonly LocalAIAnalysisService $aiAnalysisService,
+        private readonly QuestionBankService $questionBankService
+    ) {}
+
+    /**
+     * 分析考试答题数据
+     *
+     * @param array $examData 考试数据
+     * [
+     *   'exam_id' => 'exam_001',
+     *   'student_id' => 'student_001',
+     *   'questions' => [
+     *     [
+     *       'question_id' => 'Q1',
+     *       'score' => 5,
+     *       'score_obtained' => 5,
+     *       'steps' => [
+     *         ['step_index' => 1, 'is_correct' => true, 'kp_id' => 'K-SQRT-SIMPLE'],
+     *         ['step_index' => 2, 'is_correct' => true, 'kp_id' => 'K-NUM-ADD-SUB']
+     *       ]
+     *     ]
+     *   ]
+     * ]
+     *
+     * @return array 分析结果
+     */
+    public function analyzeExamAnswers(array $examData): array
+    {
+        Log::info('开始分析考试答题', [
+            'exam_id' => $examData['exam_id'] ?? 'unknown',
+            'student_id' => $examData['student_id'] ?? 'unknown',
+            'question_count' => count($examData['questions'] ?? [])
+        ]);
+
+        $studentId = $examData['student_id'];
+        $questions = $examData['questions'] ?? [];
+
+        // 1. 保存答题记录到数据库
+        $this->saveExamAnswerRecords($examData);
+
+        // 2. 获取题目知识点映射
+        $questionMappings = $this->getQuestionKnowledgeMappings($questions);
+
+        // 3. 计算每个知识点的加权掌握度
+        $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings);
+
+        // 4. 更新学生掌握度
+        $updatedMastery = $this->updateStudentMastery($studentId, $knowledgeMasteryVector);
+
+        // 5. 生成题目维度分析
+        $questionAnalysis = $this->analyzeQuestions($questions, $questionMappings);
+
+        // 6. 生成知识点维度分析
+        $knowledgePointAnalysis = $this->analyzeKnowledgePoints($knowledgeMasteryVector, $questionMappings);
+
+        // 7. 生成整体掌握度总结
+        $overallSummary = $this->generateOverallSummary($updatedMastery);
+
+        // 8. 生成智能出卷推荐依据
+        $smartQuizRecommendation = $this->generateSmartQuizRecommendation($updatedMastery);
+
+        // 9. 保存分析结果
+        $analysisResult = [
+            'exam_id' => $examData['exam_id'],
+            'student_id' => $studentId,
+            'timestamp' => now()->toISOString(),
+            'question_analysis' => $questionAnalysis,
+            'knowledge_point_analysis' => $knowledgePointAnalysis,
+            'overall_summary' => $overallSummary,
+            'smart_quiz_recommendation' => $smartQuizRecommendation,
+            'mastery_vector' => $updatedMastery,
+        ];
+
+        $this->saveAnalysisResult($studentId, $examData['exam_id'], $analysisResult);
+
+        Log::info('考试答题分析完成', [
+            'student_id' => $studentId,
+            'exam_id' => $examData['exam_id'],
+            'analyzed_knowledge_points' => count($knowledgeMasteryVector)
+        ]);
+
+        return $analysisResult;
+    }
+
+    /**
+     * 获取题目知识点映射
+     */
+    private function getQuestionKnowledgeMappings(array $questions): array
+    {
+        $mappings = [];
+        $questionIds = array_column($questions, 'question_id');
+
+        // 从题库获取题目知识点映射
+        try {
+            $response = $this->questionBankService->getQuestionsKnowledgeMapping($questionIds);
+
+            foreach ($response as $mapping) {
+                $mappings[$mapping['question_id']] = $mapping;
+            }
+        } catch (\Exception $e) {
+            Log::warning('获取题目知识点映射失败,使用默认映射', [
+                'error' => $e->getMessage(),
+                'question_ids' => $questionIds
+            ]);
+
+            // 使用默认映射:每道题至少映射到一个知识点
+            foreach ($questions as $question) {
+                $mappings[$question['question_id']] = [
+                    'question_id' => $question['question_id'],
+                    'kp_mapping' => [
+                        ['kp_id' => 'K-GENERAL', 'kp_name' => '综合', 'weight' => 1.0]
+                    ]
+                ];
+            }
+        }
+
+        return $mappings;
+    }
+
+    /**
+     * 计算知识点掌握度向量
+     * 基于文档中的简单实用更新公式
+     */
+    private function calculateKnowledgeMasteryVector(array $questions, array $questionMappings): array
+    {
+        $knowledgeScores = [];
+
+        foreach ($questions as $question) {
+            $questionId = $question['question_id'];
+            $score = floatval($question['score_obtained'] ?? 0);
+            $maxScore = floatval($question['score'] ?? $score);
+            $steps = $question['steps'] ?? [];
+
+            $mapping = $questionMappings[$questionId] ?? null;
+            if (!$mapping || !isset($mapping['kp_mapping'])) {
+                continue;
+            }
+
+            // 如果有步骤级分析,使用步骤分析
+            if (!empty($steps)) {
+                foreach ($steps as $step) {
+                    $kpId = $step['kp_id'] ?? 'K-GENERAL';
+                    $stepScore = floatval($step['score'] ?? ($maxScore / count($steps)));
+                    $stepWeight = floatval($step['weight'] ?? 1.0);
+
+                    if (!isset($knowledgeScores[$kpId])) {
+                        $knowledgeScores[$kpId] = [
+                            'total_weight' => 0,
+                            'correct_weight' => 0,
+                            'step_details' => []
+                        ];
+                    }
+
+                    $knowledgeScores[$kpId]['total_weight'] += $stepScore * $stepWeight;
+                    if ($step['is_correct']) {
+                        $knowledgeScores[$kpId]['correct_weight'] += $stepScore * $stepWeight;
+                    }
+
+                    $knowledgeScores[$kpId]['step_details'][] = [
+                        'question_id' => $questionId,
+                        'step_index' => $step['step_index'],
+                        'score' => $stepScore,
+                        'is_correct' => $step['is_correct']
+                    ];
+                }
+            } else {
+                // 没有步骤级分析,使用题目整体分析
+                foreach ($mapping['kp_mapping'] as $kpMapping) {
+                    $kpId = $kpMapping['kp_id'];
+                    $weight = floatval($kpMapping['weight'] ?? 1.0);
+                    $kpMaxScore = $maxScore * $weight;
+
+                    if (!isset($knowledgeScores[$kpId])) {
+                        $knowledgeScores[$kpId] = [
+                            'total_weight' => 0,
+                            'correct_weight' => 0,
+                            'step_details' => []
+                        ];
+                    }
+
+                    $knowledgeScores[$kpId]['total_weight'] += $kpMaxScore;
+                    if ($score > 0) {
+                        $knowledgeScores[$kpId]['correct_weight'] += $score * $weight;
+                    }
+                }
+            }
+        }
+
+        // 计算掌握度
+        $masteryVector = [];
+        foreach ($knowledgeScores as $kpId => $data) {
+            $mastery = $data['total_weight'] > 0
+                ? $data['correct_weight'] / $data['total_weight']
+                : 0;
+
+            // 置信度校正:考得越多,评价越稳定
+            $confidence = 1 - exp(-$data['total_weight'] / 5);
+
+            $masteryVector[$kpId] = [
+                'kp_id' => $kpId,
+                'mastery' => $mastery,
+                'confidence' => $confidence,
+                'total_weight' => $data['total_weight'],
+                'correct_weight' => $data['correct_weight'],
+                'step_details' => $data['step_details'],
+            ];
+        }
+
+        return $masteryVector;
+    }
+
+    /**
+     * 更新学生掌握度(与历史数据合并)
+     */
+    private function updateStudentMastery(string $studentId, array $knowledgeMasteryVector): array
+    {
+        $updatedMastery = [];
+
+        foreach ($knowledgeMasteryVector as $kpId => $data) {
+            // 获取历史掌握度
+            $historyMastery = DB::connection('pgsql')
+                ->table('student_knowledge_mastery')
+                ->where('student_id', $studentId)
+                ->where('kp_code', $kpId)
+                ->first();
+
+            $historyMasteryLevel = $historyMastery->mastery_level ?? 0.5;
+            $historyWeight = $historyMastery->total_attempts ?? 0;
+            $currentWeight = $data['total_weight'];
+
+            // 合并计算:历史权重 + 当前权重
+            $newMastery = $historyWeight > 0
+                ? ($historyWeight * $historyMasteryLevel + $currentWeight * $data['mastery'])
+                  / ($historyWeight + $currentWeight)
+                : $data['mastery'];
+
+            $newConfidence = $data['confidence'];
+
+            // 保存到数据库
+            DB::connection('pgsql')
+                ->table('student_knowledge_mastery')
+                ->updateOrInsert(
+                    ['student_id' => $studentId, 'kp_code' => $kpId],
+                    [
+                        'mastery_level' => $newMastery,
+                        'confidence_level' => $newConfidence,
+                        'total_attempts' => ($historyMastery->total_attempts ?? 0) + 1,
+                        'correct_attempts' => ($historyMastery->correct_attempts ?? 0) + intval($data['correct_weight'] > 0),
+                        'mastery_trend' => $this->determineMasteryTrend($historyMasteryLevel, $newMastery),
+                        'last_mastery_update' => now(),
+                        'updated_at' => now(),
+                    ]
+                );
+
+            $updatedMastery[$kpId] = [
+                'kp_id' => $kpId,
+                'current_mastery' => $newMastery,
+                'previous_mastery' => $historyMasteryLevel,
+                'confidence' => $newConfidence,
+                'change' => $newMastery - $historyMasteryLevel,
+                'weight' => $currentWeight
+            ];
+        }
+
+        return $updatedMastery;
+    }
+
+    /**
+     * 生成题目维度分析
+     */
+    private function analyzeQuestions(array $questions, array $questionMappings): array
+    {
+        $analysis = [];
+
+        foreach ($questions as $question) {
+            $questionId = $question['question_id'];
+            $score = floatval($question['score_obtained'] ?? 0);
+            $maxScore = floatval($question['score'] ?? $score);
+            $steps = $question['steps'] ?? [];
+
+            $mapping = $questionMappings[$questionId] ?? ['kp_mapping' => []];
+
+            // 步骤分析
+            $stepAnalysis = [];
+            if (!empty($steps)) {
+                foreach ($steps as $step) {
+                    $kpId = $step['kp_id'] ?? 'K-GENERAL';
+                    $stepAnalysis[] = [
+                        'step_index' => $step['step_index'],
+                        'is_correct' => $step['is_correct'],
+                        'kp_id' => $kpId,
+                        'description' => $step['description'] ?? ''
+                    ];
+                }
+            }
+
+            // 知识点关联
+            $knowledgePoints = array_map(function($kp) {
+                return [
+                    'kp_id' => $kp['kp_id'],
+                    'kp_name' => $kp['kp_name'] ?? $kp['kp_id'],
+                    'weight' => $kp['weight'] ?? 1.0
+                ];
+            }, $mapping['kp_mapping']);
+
+            $analysis[] = [
+                'question_id' => $questionId,
+                'score_obtained' => $score,
+                'max_score' => $maxScore,
+                'accuracy_rate' => $maxScore > 0 ? $score / $maxScore : 0,
+                'step_analysis' => $stepAnalysis,
+                'knowledge_points' => $knowledgePoints,
+                'performance_summary' => $this->generateQuestionPerformanceSummary($question, $stepAnalysis)
+            ];
+        }
+
+        return $analysis;
+    }
+
+    /**
+     * 生成知识点维度分析
+     */
+    private function analyzeKnowledgePoints(array $knowledgeMasteryVector, array $questionMappings): array
+    {
+        $analysis = [];
+
+        foreach ($knowledgeMasteryVector as $kpId => $data) {
+            $analysis[] = [
+                'kp_id' => $kpId,
+                'mastery_level' => $data['mastery'],
+                'confidence_level' => $data['confidence'],
+                'performance_in_exam' => $this->evaluatePerformanceLevel($data['mastery']),
+                'evidence_count' => count($data['step_details']),
+                'step_evidence' => $data['step_details'],
+                'recommendation' => $this->generateKnowledgePointRecommendation($data)
+            ];
+        }
+
+        return $analysis;
+    }
+
+    /**
+     * 生成整体掌握度总结
+     */
+    private function generateOverallSummary(array $updatedMastery): array
+    {
+        $knowledgePoints = array_values($updatedMastery);
+
+        if (empty($knowledgePoints)) {
+            return [
+                'total_knowledge_points' => 0,
+                'average_mastery' => 0,
+                'mastery_distribution' => [
+                    'mastered' => 0,
+                    'good' => 0,
+                    'weak' => 0
+                ],
+                'top_strengths' => [],
+                'top_weaknesses' => []
+            ];
+        }
+
+        // 计算平均掌握度
+        $averageMastery = array_sum(array_column($knowledgePoints, 'current_mastery')) / count($knowledgePoints);
+
+        // 掌握度分布
+        $mastered = array_filter($knowledgePoints, fn($kp) => $kp['current_mastery'] >= 0.85);
+        $good = array_filter($knowledgePoints, fn($kp) => $kp['current_mastery'] >= 0.70 && $kp['current_mastery'] < 0.85);
+        $weak = array_filter($knowledgePoints, fn($kp) => $kp['current_mastery'] < 0.70);
+
+        // 排序找出优势和薄弱点
+        usort($knowledgePoints, fn($a, $b) => $b['current_mastery'] <=> $a['current_mastery']);
+        $topStrengths = array_slice($knowledgePoints, 0, 3);
+        $topWeaknesses = array_slice(array_reverse($knowledgePoints), 0, 3);
+
+        return [
+            'total_knowledge_points' => count($knowledgePoints),
+            'average_mastery' => round($averageMastery, 4),
+            'mastery_distribution' => [
+                'mastered' => count($mastered),
+                'good' => count($good),
+                'weak' => count($weak)
+            ],
+            'top_strengths' => $topStrengths,
+            'top_weaknesses' => $topWeaknesses,
+            'overall_performance' => $this->evaluateOverallPerformance($averageMastery)
+        ];
+    }
+
+    /**
+     * 生成智能出卷推荐依据
+     * 基于文档中的推荐优先级算法
+     */
+    private function generateSmartQuizRecommendation(array $updatedMastery): array
+    {
+        $recommendations = [];
+
+        foreach ($updatedMastery as $kpId => $data) {
+            $mastery = $data['current_mastery'];
+            $confidence = $data['confidence'];
+            $weight = $data['weight'];
+
+            // 推荐优先级 = (1 - 掌握度) * 重要性 * 覆盖需求
+            // 重要性可以根据知识点在中考/阶段考试中的权重,这里简化为1.0
+            $importance = 1.0;
+
+            // 覆盖需求:最近没考过或考得少,值大
+            $coverageNeed = max(1.0, 1.5 - ($weight / 10));
+
+            $priority = (1 - $mastery) * $importance * $coverageNeed;
+
+            $recommendations[] = [
+                'kp_id' => $kpId,
+                'current_mastery' => $mastery,
+                'priority' => $priority,
+                'recommended_questions' => $this->calculateRecommendedQuestions($mastery),
+                'focus_type' => $this->determineFocusType($mastery)
+            ];
+        }
+
+        // 按优先级排序
+        usort($recommendations, fn($a, $b) => $b['priority'] <=> $a['priority']);
+
+        // 控制难度节奏:40%巩固型 + 40%修补型 + 20%挑战型
+        $totalRecommendations = count($recommendations);
+        $consolidation = array_slice($recommendations, 0, intval($totalRecommendations * 0.4));
+        $remediation = array_slice($recommendations, intval($totalRecommendations * 0.4), intval($totalRecommendations * 0.4));
+        $challenge = array_slice($recommendations, intval($totalRecommendations * 0.8));
+
+        return [
+            'priority_list' => $recommendations,
+            'quiz_structure' => [
+                'consolidation_type' => $consolidation,
+                'remediation_type' => $remediation,
+                'challenge_type' => $challenge
+            ],
+            'total_recommended_questions' => array_sum(array_column($recommendations, 'recommended_questions'))
+        ];
+    }
+
+    /**
+     * 保存考试答题记录
+     */
+    private function saveExamAnswerRecords(array $examData): void
+    {
+        $studentId = $examData['student_id'];
+        $examId = $examData['exam_id'];
+
+        foreach ($examData['questions'] as $question) {
+            $questionId = $question['question_id'];
+            $steps = $question['steps'] ?? [];
+
+            // 保存步骤级记录
+            if (!empty($steps)) {
+                foreach ($steps as $step) {
+                    DB::connection('pgsql')->table('student_answer_steps')->insert([
+                        'student_id' => $studentId,
+                        'exam_id' => $examId,
+                        'question_id' => $questionId,
+                        'step_index' => $step['step_index'],
+                        'kp_id' => $step['kp_id'] ?? 'K-GENERAL',
+                        'is_correct' => $step['is_correct'],
+                        'step_score' => $step['score'] ?? 0,
+                        'created_at' => now(),
+                        'updated_at' => now(),
+                    ]);
+                }
+            } else {
+                // 保存题目级记录
+                DB::connection('pgsql')->table('student_answer_questions')->insert([
+                    'student_id' => $studentId,
+                    'exam_id' => $examId,
+                    'question_id' => $questionId,
+                    'score_obtained' => $question['score_obtained'] ?? 0,
+                    'max_score' => $question['score'] ?? 0,
+                    'created_at' => now(),
+                    'updated_at' => now(),
+                ]);
+            }
+        }
+    }
+
+    /**
+     * 保存分析结果
+     */
+    private function saveAnalysisResult(string $studentId, string $examId, array $result): void
+    {
+        DB::connection('pgsql')->table('exam_analysis_results')->insert([
+            'student_id' => $studentId,
+            'exam_id' => $examId,
+            'analysis_data' => json_encode($result),
+            'created_at' => now(),
+            'updated_at' => now(),
+        ]);
+    }
+
+    /**
+     * 判断掌握度趋势
+     */
+    private function determineMasteryTrend(float $previous, float $current): string
+    {
+        $change = $current - $previous;
+
+        if ($change > 0.1) {
+            return 'improving';
+        } elseif ($change < -0.1) {
+            return 'declining';
+        } else {
+            return 'stable';
+        }
+    }
+
+    /**
+     * 评估表现水平
+     */
+    private function evaluatePerformanceLevel(float $mastery): string
+    {
+        if ($mastery >= 0.85) {
+            return 'excellent';
+        } elseif ($mastery >= 0.70) {
+            return 'good';
+        } elseif ($mastery >= 0.50) {
+            return 'fair';
+        } else {
+            return 'poor';
+        }
+    }
+
+    /**
+     * 生成题目表现总结
+     */
+    private function generateQuestionPerformanceSummary(array $question, array $stepAnalysis): string
+    {
+        if (empty($stepAnalysis)) {
+            return '整题作答';
+        }
+
+        $correctSteps = count(array_filter($stepAnalysis, fn($s) => $s['is_correct']));
+        $totalSteps = count($stepAnalysis);
+
+        if ($correctSteps === $totalSteps) {
+            return '所有步骤正确';
+        } elseif ($correctSteps > 0) {
+            return "部分正确 ({$correctSteps}/{$totalSteps} 步骤正确)";
+        } else {
+            return '所有步骤错误';
+        }
+    }
+
+    /**
+     * 生成知识点建议
+     */
+    private function generateKnowledgePointRecommendation(array $data): string
+    {
+        $mastery = $data['mastery'];
+
+        if ($mastery >= 0.85) {
+            return '掌握良好,可安排综合练习';
+        } elseif ($mastery >= 0.70) {
+            return '基本掌握,建议加强练习';
+        } elseif ($mastery >= 0.50) {
+            return '需要重点练习,建议安排专项训练';
+        } else {
+            return '薄弱知识点,建议系统学习和大量练习';
+        }
+    }
+
+    /**
+     * 评估整体表现
+     */
+    private function evaluateOverallPerformance(float $averageMastery): string
+    {
+        if ($averageMastery >= 0.85) {
+            return '优秀';
+        } elseif ($averageMastery >= 0.70) {
+            return '良好';
+        } elseif ($averageMastery >= 0.50) {
+            return '一般';
+        } else {
+            return '需加强';
+        }
+    }
+
+    /**
+     * 计算推荐题目数量
+     */
+    private function calculateRecommendedQuestions(float $mastery): int
+    {
+        if ($mastery >= 0.85) {
+            return 1; // 巩固型:1题
+        } elseif ($mastery >= 0.50) {
+            return 2; // 修补型:2题
+        } else {
+            return 3; // 挑战型:3题
+        }
+    }
+
+    /**
+     * 确定重点类型
+     */
+    private function determineFocusType(float $mastery): string
+    {
+        if ($mastery >= 0.70 && $mastery < 0.85) {
+            return 'consolidation'; // 巩固型
+        } elseif ($mastery < 0.70) {
+            return 'remediation'; // 修补型
+        } else {
+            return 'challenge'; // 挑战型
+        }
+    }
+}

+ 366 - 0
app/Services/ImportInferenceService.php

@@ -0,0 +1,366 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\MarkdownImport;
+use App\Models\SourcePaper;
+use App\Models\Textbook;
+use App\Models\TextbookCatalog;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Str;
+
+class ImportInferenceService
+{
+    /**
+     * 推断学期
+     */
+    public function inferTerm(string $context): ?string
+    {
+        if (Str::contains($context, ['上册', '上学期'])) {
+            return '上册';
+        }
+        if (Str::contains($context, ['下册', '下学期'])) {
+            return '下册';
+        }
+        return null;
+    }
+
+    /**
+     * 推断年级
+     */
+    public function inferGrade(string $context): ?string
+    {
+        foreach (['七年级' => '7', '八年级' => '8', '九年级' => '9', '高一' => '10', '高二' => '11', '高三' => '12'] as $label => $value) {
+            if (Str::contains($context, $label)) {
+                return $value;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 推断章节
+     */
+    public function inferChapter(string $context): ?string
+    {
+        if (preg_match('/第[一二三四五六七八九十]+章[^\\n]*/u', $context, $match)) {
+            return $match[0];
+        }
+        return null;
+    }
+
+    /**
+     * 匹配目录节点 ID
+     */
+    public function matchCatalogNodeId(string $context, ?int $textbookId): ?int
+    {
+        if (!$textbookId) {
+            return null;
+        }
+
+        $needle = trim($context);
+        if ($needle === '') {
+            return null;
+        }
+
+        $nodes = TextbookCatalog::query()
+            ->where('textbook_id', $textbookId)
+            ->orderBy('depth')
+            ->orderBy('sort_order')
+            ->get(['id', 'title']);
+
+        $chapterNeedle = $this->extractChapterLabel($needle);
+        $normalizedNeedle = $this->normalizeText($chapterNeedle ?: $needle);
+
+        $bestId = null;
+        $bestScore = 0;
+
+        foreach ($nodes as $node) {
+            $title = (string) $node->title;
+            if ($title === '') {
+                continue;
+            }
+
+            $normalizedTitle = $this->normalizeText($title);
+            if ($normalizedTitle === '') {
+                continue;
+            }
+
+            if (Str::contains($normalizedNeedle, $normalizedTitle) || Str::contains($normalizedTitle, $normalizedNeedle)) {
+                return (int) $node->id;
+            }
+
+            $score = 0;
+            $chapterInTitle = $this->extractChapterLabel($title);
+            if ($chapterNeedle && $chapterInTitle && $chapterNeedle === $chapterInTitle) {
+                $score += 30;
+            }
+
+            $similarity = $this->similarityScore($normalizedNeedle, $normalizedTitle);
+            $score += $similarity;
+
+            if ($score > $bestScore) {
+                $bestScore = $score;
+                $bestId = (int) $node->id;
+            }
+        }
+
+        return $bestScore >= 60 ? $bestId : null;
+    }
+
+    /**
+     * 从文件名推断教材 (兼容旧接口)
+     */
+    public function resolveTextbookFromFilename(array $parsed): ?Textbook
+    {
+        return $this->findBestTextbook([
+            'grade' => $parsed['grade'] ?? null,
+            'term' => $parsed['term'] ?? null,
+            'series' => $parsed['series'] ?? null,
+            'subject' => $parsed['subject'] ?? null,
+        ]);
+    }
+
+    /**
+     * 根据一组属性推断最匹配的教材
+     * 根据属性推断最匹配的教材
+     */
+    public function findBestTextbook(array $attributes): ?Textbook
+    {
+        // 核心参数获取
+        $seriesId = $attributes['series_id'] ?? null;
+        if (!$seriesId && !empty($attributes['series'])) {
+            $formal = $this->resolveSeries($attributes['series']);
+            $seriesId = $formal?->id;
+        }
+
+        if (!$seriesId) {
+            return null;
+        }
+
+        $grade = (string)($attributes['grade'] ?? '');
+        $term = (string)($attributes['term'] ?? '');
+        $semester = $this->termToSemester($term);
+
+        // 仅在当前系列下筛选
+        $textbooks = Textbook::query()->where('series_id', $seriesId)->get();
+        if ($textbooks->isEmpty()) {
+            return null;
+        }
+
+        $best = null;
+        $maxScore = -1;
+
+        foreach ($textbooks as $tb) {
+            $score = 20; // 基础系列分
+
+            // 年级匹配
+            if ($grade && (int)$tb->grade === (int)$grade) {
+                $score += 5;
+            }
+
+            // 学期匹配
+            if ($semester && (int)$tb->semester === $semester) {
+                $score += 5;
+            }
+
+            if ($score > $maxScore) {
+                $maxScore = $score;
+                $best = $tb;
+            }
+        }
+
+        return ($best && $maxScore >= 20) ? $best : null;
+    }
+
+    /**
+     * 推断卷子类型
+     */
+    public function inferSourceType(string $context): ?string
+    {
+        $map = [
+            '单元' => '单元测试',
+            '月考' => '月考',
+            '期中' => '期中考试',
+            '期末' => '期末考试',
+            '中考' => '中考套卷',
+            '模拟' => '模拟考试',
+            '考前' => '模拟考试',
+            '真题' => '真题卷',
+            '周测' => '周练/周测',
+            '周练' => '周练/周测',
+            '练' => '课时练习',
+        ];
+
+        foreach ($map as $key => $val) {
+            if (Str::contains($context, $key)) {
+                return $val;
+            }
+        }
+
+        return '综合测试';
+    }
+
+    /**
+     * 将解析出的系列俗称 (如 北师大版) 匹配到数据库正式系列模型 (如 北师大版(新))
+     */
+    public function resolveSeries(?string $hint): ?\App\Models\TextbookSeries
+    {
+        if (!$hint) {
+            return null;
+        }
+
+        $hint = trim((string)$hint);
+        $series = \App\Models\TextbookSeries::query()->get();
+        
+        $best = null;
+        $bestScore = 0;
+
+        foreach ($series as $s) {
+            $name = (string)$s->name;
+            // 标准化名称:去掉括号里的(新)、(旧)等干扰项
+            $standardName = preg_replace('/((新|旧|修订版|实验版|.*制))/u', '', $name);
+            
+            // 包含匹配:如 "北师大版" 包含在 "北师大版(新)" 剥离后的 "北师大版" 中
+            if (Str::contains($name, $hint) || Str::contains($hint, $name) || 
+                Str::contains($hint, $standardName) || Str::contains($standardName, $hint)) {
+                return $s;
+            }
+            
+            // 模糊得分
+            $score = $this->similarityScore($this->normalizeText($hint), $this->normalizeText($name));
+            if ($score > $bestScore) {
+                $bestScore = $score;
+                $best = $s;
+            }
+        }
+
+        return $bestScore >= 70 ? $best : null;
+    }
+
+    /**
+     * 获取教材建议
+     */
+    public function getTextbookSuggestions(SourcePaper $paper, array $parsedImportFilename = []): array
+    {
+        $title = (string) ($paper->title ?? $paper->full_title ?? '');
+        $context = Str::lower($title);
+        $grade = $paper->grade ? (int) $paper->grade : ($parsedImportFilename['grade'] ?? null);
+        $semester = $this->termToSemester($paper->term) ?? $this->termToSemester($parsedImportFilename['term'] ?? null);
+        $seriesHint = $paper->textbook_series ?: ($parsedImportFilename['series'] ?? null);
+        $subjectHint = $parsedImportFilename['subject'] ?? null;
+
+        $suggestions = [];
+        $textbooks = Textbook::query()->with('series')->get();
+        foreach ($textbooks as $textbook) {
+            $score = 0;
+
+            if ($grade && (int) $textbook->grade === $grade) {
+                $score += 3;
+            }
+            if ($semester && (int) $textbook->semester === $semester) {
+                $score += 3;
+            }
+
+            $official = Str::lower((string) $textbook->official_title);
+            if ($official !== '' && Str::contains($context, $official)) {
+                $score += 4;
+            }
+
+            $aliases = $this->normalizeAliases($textbook->aliases ?? []);
+            foreach ($aliases as $alias) {
+                $alias = Str::lower((string) $alias);
+                if ($alias !== '' && Str::contains($context, $alias)) {
+                    $score += 2;
+                }
+            }
+
+            $seriesName = $textbook->series?->name ?? null;
+            if ($seriesHint && $seriesName && (Str::contains((string) $seriesName, (string) $seriesHint) || Str::contains((string) $seriesHint, (string) $seriesName))) {
+                $score += 5;
+            }
+
+            if ($subjectHint) {
+                $subjectHint = Str::lower((string) $subjectHint);
+                $official = Str::lower((string) $textbook->official_title);
+                if ($official !== '' && Str::contains($official, $subjectHint)) {
+                    $score += 1;
+                }
+            }
+
+            if ($score > 0) {
+                $suggestions[] = [
+                    'id' => $textbook->id,
+                    'title' => $textbook->official_title,
+                    'series' => $textbook->series?->name ?? '未归类系列',
+                    'grade' => $textbook->grade,
+                    'semester' => $textbook->semester,
+                    'score' => $score,
+                ];
+            }
+        }
+
+        usort($suggestions, fn ($a, $b) => $b['score'] <=> $a['score']);
+        return array_slice($suggestions, 0, 5);
+    }
+
+    /**
+     * 将学期字符串归一化为 semester 数字 (1=上, 2=下)
+     */
+    public function termToSemester(?string $term): ?int
+    {
+        if (!$term) {
+            return null;
+        }
+        if (Str::contains($term, '上')) {
+            return 1;
+        }
+        if (Str::contains($term, '下')) {
+            return 2;
+        }
+        return null;
+    }
+
+    private function normalizeText(string $text): string
+    {
+        $text = Str::lower($text);
+        $text = preg_replace('/\\s+/u', '', $text);
+        $text = preg_replace('/[,。.、,\\.\\-—_()\\(\\)【】\\[\\]::;;!!??]/u', '', $text);
+        return $text ?? '';
+    }
+
+    private function extractChapterLabel(string $text): ?string
+    {
+        if (preg_match('/第\\s*[一二三四五六七八九十0-9]+\\s*[章节]/u', $text, $match)) {
+            return trim($match[0]);
+        }
+        return null;
+    }
+
+    private function similarityScore(string $a, string $b): int
+    {
+        if ($a === '' || $b === '') {
+            return 0;
+        }
+
+        similar_text($a, $b, $percent);
+        return (int) round($percent);
+    }
+
+    private function normalizeAliases(array|string|null $aliases): array
+    {
+        if (is_array($aliases)) {
+            return $aliases;
+        }
+        if (!is_string($aliases) || trim($aliases) === '') {
+            return [];
+        }
+
+        $decoded = json_decode($aliases, true);
+        if (is_array($decoded)) {
+            return $decoded;
+        }
+
+        return array_values(array_filter(array_map('trim', preg_split('/[,,;;\\n]+/u', $aliases))));
+    }
+}

+ 29 - 0
app/Services/MarkdownQuestionParser.php

@@ -165,8 +165,20 @@ class MarkdownQuestionParser
                 'tables' => isset($result['tables']) && is_array($result['tables']) ? $result['tables'] : [],
                 'is_question_candidate' => (bool) ($result['is_question_candidate'] ?? $result['is_question'] ?? false),
                 'ai_confidence' => isset($result['ai_confidence']) ? (float) $result['ai_confidence'] : (isset($result['confidence']) ? (float) $result['confidence'] : null),
+                'answer' => $result['answer'] ?? null,
+                'solution' => $result['solution'] ?? null,
+                'solution_steps' => $result['solution_steps'] ?? $result['steps'] ?? [],
             ];
 
+            // 如果是简答题(没有选项)且没有分步解析,尝试使用专门的 Prompt 补全
+            if (empty($normalized['options']) && empty($normalized['solution_steps']) && $normalized['is_question_candidate']) {
+                $stepResult = $this->refineSolutionSteps($rawMarkdown);
+                if ($stepResult) {
+                    $normalized['solution'] = $stepResult['solution'] ?? $normalized['solution'];
+                    $normalized['solution_steps'] = $stepResult['steps'] ?? [];
+                }
+            }
+
             Log::debug('AI structured parse response', [
                 'driver' => $this->aiDriver,
                 'index' => $index,
@@ -377,6 +389,23 @@ class MarkdownQuestionParser
         return $this->parseJsonResponse($content);
     }
 
+    /**
+     * 精修简答题的解题步骤
+     */
+    private function refineSolutionSteps(string $rawMarkdown): ?array
+    {
+        $prompt = app(QuestionPromptService::class)->buildSolutionStepsPrompt($rawMarkdown);
+
+        try {
+            return $this->callAiApi($prompt);
+        } catch (\Throwable $e) {
+            Log::warning('Refine solution steps failed', [
+                'error' => $e->getMessage(),
+            ]);
+            return null;
+        }
+    }
+
     /**
      * 解析 AI 返回的 JSON
      */

+ 15 - 0
app/Services/PaperPartExtractorService.php

@@ -44,8 +44,23 @@ class PaperPartExtractorService
         $current = ['title' => null, 'buffer' => []];
 
         $partPattern = '/^(#{2,3})\s*(第? ?[一二三四五六七八九十0-9IVX]+[部分卷]|选择题|填空题|解答题|综合题|计算题|应用题)/u';
+        $commentPattern = '/<!--\s*part:\s*(.+?)\s*-->/i';
 
         foreach ($lines as $line) {
+            $trimmed = trim($line);
+
+            // 支持隐藏的区块标记
+            if (preg_match($commentPattern, $trimmed, $cm)) {
+                if (!empty($current['buffer'])) {
+                    $segments[] = $this->finalizeSegment($current);
+                }
+                $current = [
+                    'title' => trim($cm[1]),
+                    'buffer' => [$line],
+                ];
+                continue;
+            }
+
             if (preg_match($partPattern, $line, $m)) {
                 if (!empty($current['buffer'])) {
                     $segments[] = $this->finalizeSegment($current);

+ 18 - 14
app/Services/PdfStorageService.php

@@ -101,25 +101,32 @@ class PdfStorageService
      * Chunsun云存储上传(POST multipart/form-data)
      * 环境变量:
      * PDF_STORAGE_DRIVER=chunsun
-     * CHUNSUN_UPLOAD_URL=https://crmapi.dcjxb.yunzhixue.cn
+     * CHUNSUN_UPLOAD_URL=https://crmapi.dcjxb.yunzhixue.cn/file/upload
      */
     private function putChunsun(string $path, string $binary): ?string
     {
-        $uploadUrl = env('CHUNSUN_UPLOAD_URL', 'https://crmapi.dcjxb.yunzhixue.cn');
+        $uploadUrl = env('CHUNSUN_UPLOAD_URL', 'https://crmapi.dcjxb.yunzhixue.cn/file/upload');
 
         try {
-            // 创建临时文件
-            $tempFile = tempnam(sys_get_temp_dir(), 'chunsun_pdf_') . '.pdf';
-            file_put_contents($tempFile, $binary);
-
-            // 发送POST请求上传文件
+            // 获取扩展名并决定 MIME
+            $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg');
+            $mimeMap = [
+                'pdf' => 'application/pdf',
+                'md' => 'text/markdown',
+                'markdown' => 'text/markdown',
+                'txt' => 'text/plain',
+                'png' => 'image/png',
+                'jpg' => 'image/jpeg',
+                'jpeg' => 'image/jpeg',
+            ];
+            $mimeType = $mimeMap[$extension] ?? 'application/octet-stream';
+            $baseName = basename($path);
+
+            // 发送POST请求上传内容
             $response = Http::timeout(60)
-                ->attach('file', file_get_contents($tempFile), basename($tempFile) . '.pdf')
+                ->attach('file', $binary, $baseName, ['Content-Type' => $mimeType])
                 ->post($uploadUrl);
 
-            // 清理临时文件
-            @unlink($tempFile);
-
             if (!$response->successful()) {
                 Log::error('PdfStorageService: Chunsun上传失败', [
                     'status' => $response->status(),
@@ -148,12 +155,10 @@ class PdfStorageService
             }
 
             $uploadedUrl = $data['data']['url'];
-            $uploadedName = $data['data']['name'] ?? null;
 
             Log::info('PdfStorageService: Chunsun上传成功', [
                 'path' => $path,
                 'url' => $uploadedUrl,
-                'name' => $uploadedName,
             ]);
 
             return $uploadedUrl;
@@ -161,7 +166,6 @@ class PdfStorageService
             Log::error('PdfStorageService: Chunsun上传异常', [
                 'error' => $e->getMessage(),
                 'path' => $path,
-                'trace' => $e->getTraceAsString(),
             ]);
             return null;
         }

+ 166 - 4
app/Services/PromptService.php

@@ -7,11 +7,28 @@ use Illuminate\Support\Facades\Log;
 
 class PromptService
 {
+    private const DEFAULT_QUESTION_PROMPT_NAME = 'question_generation_default';
+    private const DEFAULT_QUESTION_PROMPT_TYPE = 'question_generation';
+    private const DEFAULT_ENRICH_PROMPT_NAME = 'question_enrich_default';
+    private const DEFAULT_ENRICH_PROMPT_TYPE = 'question_enrich';
+    private const DEFAULT_SOLUTION_PROMPT_NAME = 'question_solution_regen_default';
+    private const DEFAULT_SOLUTION_PROMPT_TYPE = 'question_solution_regen';
+
     /**
      * 获取提示词列表
      */
     public function listPrompts(?string $type = null, ?string $active = null): array
     {
+        if ($type === null || $type === self::DEFAULT_QUESTION_PROMPT_TYPE) {
+            $this->ensureDefaultQuestionPrompt();
+        }
+        if ($type === null || $type === self::DEFAULT_ENRICH_PROMPT_TYPE) {
+            $this->ensureDefaultEnrichPrompt();
+        }
+        if ($type === null || $type === self::DEFAULT_SOLUTION_PROMPT_TYPE) {
+            $this->ensureDefaultSolutionPrompt();
+        }
+
         $query = PromptTemplate::query();
         if ($type) {
             $query->where('template_type', $type);
@@ -129,11 +146,15 @@ class PromptService
 2. 难度分布:基础({basic_ratio}%) + 中等({intermediate_ratio}%) + 拔高({advanced_ratio}%)
 3. 题型分配:选择题({choice}道) + 填空题({fill}道) + 解答题({solution}道)
 
+【难度提示(来源于卷子位置)】
+- 若提供题目编号/位置:{question_index} / {question_position_hint}
+- 可据此对难度系数做初步判断:卷首偏基础,卷中偏中等,卷末偏拔高
+
 【技能覆盖】
 {skill_coverage}
 
 【图示处理】
-- 如果题涉及图形/示意图/坐标系/几何草图,必须在题干内内嵌一段完整的 <svg> 标签来还原图形;不要使用外链图片、base64 或占位符。
+- 如果题涉及图形/示意图/坐标系/几何草图,必须在题干内内嵌一段完整的 <svg> 标签来还原图形;不要使用外链图片、base64 或占位符。
 - SVG 要包含明确的宽高(建议 260~360 像素),只使用基础图元(line、rect、circle、polygon、path、text),并给出必要的坐标、角点和标注文本。
 - 确保题干文本描述与 SVG 一致,例如“如图所示”后紧跟 SVG,且 SVG 放在题干末尾即可被前端直接渲染。
 
@@ -151,10 +172,78 @@ class PromptService
       "id": "唯一标识",
       "stem": "题干",
       "answer": "标准答案",
-      "solution": "详细解答",
-      "difficulty": 难度值(0.3/0.6/0.85),
-      "skill": "关联技能"
+      "solution": ["详细解答1", "详细解答2", "详细解答3"],
+      "difficulty": 0.6,
+      "question_type": "choice/fill/answer",
+      "skills": ["技能点1", "技能点2"],
+      "knowledge_points": ["{knowledge_point}"]
+    }
+  ]
+}';
+    }
+
+    public function getDefaultEnrichPromptTemplate(): string
+    {
+        return '你是一名“数学题目完善助手”。给定原题干与可选图片外链,请补全题目关键信息并做轻量修订。
+
+要求:
+- 只输出 JSON
+- 必须包含字段:stem, options, answer, solution, question_type, difficulty, knowledge_points, solution_steps
+- 可选字段:abilities(能力/技能点数组)
+- stem 只做轻微修订(排版/符号/错别字),不得改题意
+- 若提供图片外链(image_urls),可结合图片理解题意,但不要生成 SVG
+- question_type 仅允许:choice / fill / answer
+- answer 类题目必须提供 solution_steps(分步评分),每步包含 score 与 kp_codes
+- knowledge_points 为题目级知识点列表
+
+材料内容(含题干与图片):
+{content}
+
+输出 JSON 示例:
+{
+  "stem": "...",
+  "options": {"A": "...", "B": "..."},
+  "answer": "...",
+  "solution": "...",
+  "question_type": "answer",
+  "difficulty": 0.6,
+  "knowledge_points": ["A01"],
+  "solution_steps": [
+    {"step_index": 1, "title": "...", "content": "...", "score": 4, "kp_codes": ["A01"]}
+  ],
+  "abilities": ["计算能力", "分析能力"]
+}';
     }
+
+    public function getDefaultSolutionPromptTemplate(): string
+    {
+        return '你是一名“中学数学解题专家”。给定题干、正确答案与可选图片外链,请生成与答案一致的详细解题过程。
+
+要求:
+- 只输出 JSON
+- 必须包含字段:solution, steps
+- solution 为简洁的整体思路(不写空话)
+- steps 为数组,每步包含:step_index, title, content, score, kp_codes
+- steps 必须给出可操作的计算/推理过程,避免“思想/方法类空话”
+- 若题型为选择/填空,也要给出关键推理步骤,但 steps 不能省略核心计算
+- 严格保证最终结论与 provided_answer 一致
+- 若提供图片外链(image_urls),可结合图片理解题意,但不要生成 SVG
+
+题干:
+{stem}
+
+正确答案:
+{provided_answer}
+
+图片外链(如有):
+{image_urls}
+
+输出 JSON 示例:
+{
+  "solution": "整体解题思路概述",
+  "steps": [
+    {"step_index": 1, "title": "列式", "content": "...", "score": 4, "kp_codes": ["A01"]},
+    {"step_index": 2, "title": "求解", "content": "...", "score": 6, "kp_codes": ["A01"]}
   ]
 }';
     }
@@ -185,6 +274,79 @@ class PromptService
         return [];
     }
 
+    private function ensureDefaultQuestionPrompt(): void
+    {
+        $exists = PromptTemplate::query()
+            ->where('template_name', self::DEFAULT_QUESTION_PROMPT_NAME)
+            ->exists();
+        if ($exists) {
+            return;
+        }
+
+        $this->savePrompt([
+            'template_name' => self::DEFAULT_QUESTION_PROMPT_NAME,
+            'template_type' => self::DEFAULT_QUESTION_PROMPT_TYPE,
+            'template_content' => $this->getDefaultPromptTemplate(),
+            'variables' => json_encode([
+            'knowledge_point',
+            'grade_level',
+            'basic_ratio',
+            'intermediate_ratio',
+            'advanced_ratio',
+            'choice',
+            'fill',
+            'solution',
+            'skill_coverage',
+            'question_index',
+            'question_position_hint',
+            'count',
+        ], JSON_UNESCAPED_UNICODE),
+            'description' => '默认知识点题目生成模板(题型+难度分布)',
+            'tags' => json_encode(['default', 'knowledge_point'], JSON_UNESCAPED_UNICODE),
+            'is_active' => 'yes',
+        ]);
+    }
+
+    private function ensureDefaultEnrichPrompt(): void
+    {
+        $exists = PromptTemplate::query()
+            ->where('template_name', self::DEFAULT_ENRICH_PROMPT_NAME)
+            ->exists();
+        if ($exists) {
+            return;
+        }
+
+        $this->savePrompt([
+            'template_name' => self::DEFAULT_ENRICH_PROMPT_NAME,
+            'template_type' => self::DEFAULT_ENRICH_PROMPT_TYPE,
+            'template_content' => $this->getDefaultEnrichPromptTemplate(),
+            'variables' => json_encode(['content'], JSON_UNESCAPED_UNICODE),
+            'description' => '基于题干+图片的题目信息补全模板',
+            'tags' => json_encode(['default', 'enrich'], JSON_UNESCAPED_UNICODE),
+            'is_active' => 'yes',
+        ]);
+    }
+
+    private function ensureDefaultSolutionPrompt(): void
+    {
+        $exists = PromptTemplate::query()
+            ->where('template_name', self::DEFAULT_SOLUTION_PROMPT_NAME)
+            ->exists();
+        if ($exists) {
+            return;
+        }
+
+        $this->savePrompt([
+            'template_name' => self::DEFAULT_SOLUTION_PROMPT_NAME,
+            'template_type' => self::DEFAULT_SOLUTION_PROMPT_TYPE,
+            'template_content' => $this->getDefaultSolutionPromptTemplate(),
+            'variables' => json_encode(['stem', 'provided_answer', 'image_urls'], JSON_UNESCAPED_UNICODE),
+            'description' => '基于答案重写解题思路的系统模板',
+            'tags' => json_encode(['default', 'solution'], JSON_UNESCAPED_UNICODE),
+            'is_active' => 'yes',
+        ]);
+    }
+
     private function mapPrompt(PromptTemplate $prompt): array
     {
         $variables = $prompt->variables ?? [];

+ 547 - 0
app/Services/QuestionCandidateToQuestionService.php

@@ -0,0 +1,547 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PreQuestionCandidate;
+use App\Models\Question;
+use App\Models\QuestionKpRelation;
+use App\Models\QuestionMeta;
+use App\Models\KnowledgePoint;
+use App\Models\TextbookCatalog;
+use App\Services\AiKnowledgeService;
+use App\Services\AiSolutionService;
+use App\Services\QuestionGenerationService;
+use App\Services\PdfStorageService;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class QuestionCandidateToQuestionService
+{
+    public function __construct(private readonly PdfStorageService $uploader)
+    {
+    }
+
+    /**
+     * 将源卷子下已校对的候选题入库到 questions。
+     */
+    public function promoteFromSourcePapers(Collection $papers): array
+    {
+        $summary = [
+            'processed' => 0,
+            'skipped' => 0,
+            'errors' => 0,
+        ];
+
+        foreach ($papers as $paper) {
+            $candidates = $paper->candidates()
+                ->whereIn('status', [
+                    PreQuestionCandidate::STATUS_REVIEWED,
+                    PreQuestionCandidate::STATUS_PENDING,
+                    PreQuestionCandidate::STATUS_ACCEPTED,
+                ])
+                ->where('is_question_candidate', true)
+                ->get();
+
+            $result = $this->promoteCandidates($candidates);
+            $summary['processed'] += $result['processed'];
+            $summary['skipped'] += $result['skipped'];
+            $summary['errors'] += $result['errors'];
+        }
+
+        return $summary;
+    }
+
+    /**
+     * 将候选题集合批量入库到 questions。
+     */
+    public function promoteCandidates(Collection $candidates): array
+    {
+        $summary = [
+            'processed' => 0,
+            'skipped' => 0,
+            'errors' => 0,
+        ];
+
+        foreach ($candidates as $candidate) {
+            try {
+                $this->hydrateQuestionDetails($candidate);
+                $this->hydrateKnowledgePoints($candidate);
+
+                $validationErrors = $this->validateCandidate($candidate);
+                if (!empty($validationErrors)) {
+                    $summary['errors']++;
+                    Log::warning('Candidate validation failed during promotion', [
+                        'candidate_id' => $candidate->id,
+                        'errors' => $validationErrors,
+                    ]);
+                    continue;
+                }
+
+                $question = $this->promoteCandidate($candidate);
+                if ($question) {
+                    $summary['processed']++;
+                } else {
+                    $summary['skipped']++;
+                }
+            } catch (\Throwable $e) {
+                $summary['errors']++;
+                Log::error('Failed to promote candidate to question', [
+                    'candidate_id' => $candidate->id,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $summary;
+    }
+
+    private function promoteCandidate(PreQuestionCandidate $candidate): ?Question
+    {
+        $meta = $candidate->meta ?? [];
+        if (!empty($meta['question_id'])) {
+            $existing = Question::query()->find($meta['question_id']);
+            if ($existing) {
+                return null;
+            }
+            Log::warning('Candidate has stale question_id, re-promoting', [
+                'candidate_id' => $candidate->id,
+                'question_id' => $meta['question_id'],
+            ]);
+            unset($meta['question_id']);
+        }
+
+        return DB::transaction(function () use ($candidate, $meta) {
+            $generated = $meta['generated_question'] ?? [];
+            $uploadedImages = $this->uploadCandidateImages($candidate);
+            $questionType = $this->normalizeQuestionType(
+                is_string($meta['question_type'] ?? null) ? $meta['question_type'] : ($generated['question_type'] ?? null),
+                $candidate
+            );
+
+            $kpCodes = $this->normalizeKpCodes($meta['kp_codes'] ?? ($generated['knowledge_points'] ?? []));
+            $primaryKp = $kpCodes[0] ?? null;
+            $solutionSteps = $this->resolveSolutionSteps($candidate, $questionType, $meta);
+            if (!empty($solutionSteps['steps'])) {
+                $meta['solution_steps'] = $solutionSteps['steps'];
+            }
+            if (!empty($solutionSteps['solution']) && empty($meta['solution'])) {
+                $meta['solution'] = $solutionSteps['solution'];
+            }
+
+            $questionCode = sprintf('CAND-%d', $candidate->id);
+            $options = $candidate->options ?: ($generated['options'] ?? null);
+            if (is_string($options)) {
+                $decoded = json_decode($options, true);
+                $options = is_array($decoded) ? $decoded : null;
+            }
+
+            $question = Question::updateOrCreate(
+                ['question_code' => $questionCode],
+                [
+                    'question_type' => $questionType,
+                    'kp_code' => $primaryKp,
+                    'stem' => $candidate->stem ?: ($generated['stem'] ?? null) ?: $candidate->raw_text ?: $candidate->raw_markdown,
+                    'options' => $options,
+                    'answer' => $meta['answer'] ?? ($generated['answer'] ?? null),
+                    'solution' => $meta['solution'] ?? ($generated['solution'] ?? null),
+                    'difficulty' => $meta['difficulty'] ?? ($generated['difficulty'] ?? null),
+                    'source_file_id' => $candidate->source_file_id,
+                    'source_paper_id' => $candidate->source_paper_id,
+                    'paper_part_id' => $candidate->part_id,
+                    'textbook_id' => $candidate->sourcePaper?->textbook_id,
+                    'source' => 'markdown_import',
+                    'tags' => $this->joinTags($meta['tags'] ?? []),
+                    'meta' => [
+                        'candidate_id' => $candidate->id,
+                        'import_id' => $candidate->import_id,
+                        'images' => $uploadedImages,
+                        'solution_steps' => $meta['solution_steps'] ?? [],
+                        'generated_question' => $generated,
+                    ],
+                ]
+            );
+
+            $abilities = $meta['abilities'] ?? ($generated['abilities'] ?? []);
+            QuestionMeta::updateOrCreate(
+                ['question_id' => $question->id],
+                [
+                    'abilities' => $abilities,
+                    'generation_info' => [
+                        'source' => 'candidate',
+                        'candidate_id' => $candidate->id,
+                    ],
+                    'review_status' => 'reviewed',
+                ]
+            );
+
+            $this->syncKpRelations($question->id, $kpCodes);
+
+            $meta['question_id'] = $question->id;
+            $meta['images_uploaded'] = !empty($uploadedImages);
+            $candidate->update([
+                'images' => $uploadedImages,
+                'status' => PreQuestionCandidate::STATUS_ACCEPTED,
+                'meta' => $meta,
+            ]);
+
+            return $question;
+        });
+    }
+
+    private function uploadCandidateImages(PreQuestionCandidate $candidate): array
+    {
+        $meta = $candidate->meta ?? [];
+        $images = $candidate->images ?? [];
+        if (is_string($images)) {
+            $decoded = json_decode($images, true);
+            $images = is_array($decoded) ? $decoded : [];
+        }
+
+        if (empty($images)) {
+            return [];
+        }
+
+        if (!empty($meta['images_uploaded'])) {
+            return $images;
+        }
+
+        $uploadedUrls = [];
+        foreach ($images as $idx => $url) {
+            $extension = pathinfo(parse_url((string) $url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'jpg';
+            $path = "questions/images/{$candidate->id}_{$idx}.{$extension}";
+            $uploadedUrls[] = $this->uploader->put($path, (string)@file_get_contents($url)) ?: $url;
+        }
+
+        return $uploadedUrls;
+    }
+
+    private function normalizeQuestionType(?string $type, PreQuestionCandidate $candidate): string
+    {
+        $type = strtolower(trim((string) $type));
+        $map = [
+            'choice' => 'choice',
+            'fill' => 'fill',
+            'answer' => 'answer',
+            '选择题' => 'choice',
+            '填空题' => 'fill',
+            '解答题' => 'answer',
+            '简答题' => 'answer',
+            '计算题' => 'answer',
+        ];
+
+        if ($type !== '' && isset($map[$type])) {
+            return $map[$type];
+        }
+
+        if (!empty($candidate->options)) {
+            return 'choice';
+        }
+
+        $stem = (string) ($candidate->stem ?? $candidate->raw_markdown ?? '');
+        if (preg_match('/_{2,}|\\(\\s*\\)/u', $stem)) {
+            return 'fill';
+        }
+
+        return 'answer';
+    }
+
+    private function normalizeKpCodes(array|string $kpCodes): array
+    {
+        if (is_string($kpCodes)) {
+            $kpCodes = preg_split('/[,,\\s]+/u', $kpCodes) ?: [];
+        }
+
+        return array_values(array_filter(array_map('trim', $kpCodes)));
+    }
+
+    private function syncKpRelations(int $questionId, array $kpCodes): void
+    {
+        if (empty($kpCodes)) {
+            return;
+        }
+
+        QuestionKpRelation::query()
+            ->where('question_id', $questionId)
+            ->whereNotIn('kp_code', $kpCodes)
+            ->delete();
+
+        foreach ($kpCodes as $code) {
+            QuestionKpRelation::updateOrCreate(
+                [
+                    'question_id' => $questionId,
+                    'kp_code' => $code,
+                ],
+                [
+                    'weight' => 1.0,
+                ]
+            );
+        }
+    }
+
+    private function joinTags(array|string $tags): ?string
+    {
+        if (is_string($tags)) {
+            $tags = preg_split('/[,,\\s]+/u', $tags) ?: [];
+        }
+
+        $tags = array_values(array_filter(array_map('trim', $tags)));
+        return empty($tags) ? null : implode(',', $tags);
+    }
+
+    /**
+     * 验证候选题是否具备入库条件
+     */
+    public function validateCandidate(PreQuestionCandidate $candidate): array
+    {
+        $errors = [];
+        $meta = $candidate->meta ?? [];
+
+        if (empty($candidate->stem) && empty($candidate->raw_markdown)) {
+            $errors[] = '题干内容为空';
+        }
+
+        if (empty($candidate->sourcePaper?->textbook_id)) {
+            $errors[] = '未设置教材信息';
+        }
+
+        if (empty($meta['question_type']) && empty($candidate->question_type)) {
+            // 虽然 promoteCandidate 会自动兜底题型,但建议在校对阶段明确
+        }
+
+        return $errors;
+    }
+
+    private function hydrateQuestionDetails(PreQuestionCandidate $candidate): void
+    {
+        $meta = $candidate->meta ?? [];
+        $generated = $meta['generated_question'] ?? [];
+        if (!empty($generated)) {
+            return;
+        }
+
+        $needs = false;
+        $fields = [
+            $meta['answer'] ?? null,
+            $meta['solution'] ?? null,
+            $meta['difficulty'] ?? null,
+            $meta['question_type'] ?? null,
+        ];
+        foreach ($fields as $value) {
+            if (empty($value)) {
+                $needs = true;
+                break;
+            }
+        }
+
+        if (empty($meta['kp_codes']) && empty($candidate->kp_code)) {
+            $needs = true;
+        }
+
+        $questionType = $this->normalizeQuestionType(
+            is_string($meta['question_type'] ?? null) ? $meta['question_type'] : null,
+            $candidate
+        );
+        if ($questionType === 'answer' && empty($meta['solution_steps'])) {
+            $needs = true;
+        }
+
+        if (!$needs) {
+            return;
+        }
+
+        $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown);
+        if ($questionText === '') {
+            return;
+        }
+
+        $context = $this->buildGenerationContext($candidate->sourcePaper);
+        $images = $candidate->images ?? [];
+        if (is_string($images)) {
+            $decoded = json_decode($images, true);
+            $images = is_array($decoded) ? $decoded : [];
+        }
+        $imageText = '';
+        if (!empty($images)) {
+            $imageText = "image_urls:\n" . implode("\n", array_map('strval', $images));
+        }
+
+        $parts = [];
+        if ($context) {
+            $parts[] = $context;
+        }
+        if ($imageText !== '') {
+            $parts[] = $imageText;
+        }
+        $parts[] = "题目内容:\n" . $questionText;
+        $sourceText = implode("\n\n", $parts);
+
+        $result = app(QuestionGenerationService::class)->generateFromSource($sourceText);
+        if (empty($result['success'])) {
+            Log::warning('AI question generation failed', [
+                'candidate_id' => $candidate->id,
+                'message' => $result['message'] ?? 'unknown',
+            ]);
+            return;
+        }
+
+        $generated = $result['question'] ?? [];
+        if (empty($generated)) {
+            return;
+        }
+
+        if (empty($meta['answer']) && !empty($generated['answer'])) {
+            $meta['answer'] = $generated['answer'];
+        }
+        if (empty($meta['solution']) && !empty($generated['solution'])) {
+            $meta['solution'] = $generated['solution'];
+        }
+        if (empty($meta['difficulty']) && isset($generated['difficulty'])) {
+            $meta['difficulty'] = $generated['difficulty'];
+        }
+        if (empty($meta['question_type']) && !empty($generated['question_type'])) {
+            $meta['question_type'] = $generated['question_type'];
+        }
+        if (empty($meta['kp_codes']) && !empty($generated['knowledge_points'])) {
+            $meta['kp_codes'] = $generated['knowledge_points'];
+        }
+        if (empty($meta['solution_steps']) && !empty($generated['solution_steps'])) {
+            $meta['solution_steps'] = $generated['solution_steps'];
+        }
+        if (empty($meta['abilities']) && !empty($generated['abilities'])) {
+            $meta['abilities'] = $generated['abilities'];
+        }
+
+        $meta['generated_question'] = $generated;
+        $candidate->meta = $meta;
+    }
+
+    private function hydrateKnowledgePoints(PreQuestionCandidate $candidate): void
+    {
+        $meta = $candidate->meta ?? [];
+        $kpCodes = $this->normalizeKpCodes($meta['kp_codes'] ?? ($candidate->kp_code ?? []));
+        if (!empty($kpCodes)) {
+            return;
+        }
+
+        $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown);
+        if ($questionText === '') {
+            return;
+        }
+
+        $paper = $candidate->sourcePaper;
+        $context = $this->buildKnowledgeMatchContext($paper);
+        $candidates = $this->buildKnowledgePointPool($paper);
+
+        $matches = app(AiKnowledgeService::class)->matchKnowledgePointsByAi($questionText, $candidates, $context);
+        if (empty($matches)) {
+            Log::warning('AI knowledge match returned empty', [
+                'candidate_id' => $candidate->id,
+                'paper_id' => $candidate->source_paper_id,
+            ]);
+            return;
+        }
+
+        $meta['kp_codes'] = array_values(array_unique(array_filter(array_map(
+            fn ($item) => $item['kp_code'] ?? null,
+            $matches
+        ))));
+        $meta['kp_weights'] = $matches;
+
+        $candidate->meta = $meta;
+    }
+
+    private function buildKnowledgeMatchContext(?\App\Models\SourcePaper $paper): ?string
+    {
+        if (!$paper) {
+            return null;
+        }
+
+        $lines = [];
+        if (!empty($paper->grade)) {
+            $lines[] = '年级: ' . $paper->grade;
+        }
+        if (!empty($paper->term)) {
+            $lines[] = '学期: ' . $paper->term;
+        }
+        if (!empty($paper->textbook_id)) {
+            $textbook = $paper->textbook;
+            if ($textbook) {
+                $lines[] = '教材: ' . ($textbook->official_title ?? $textbook->title ?? $textbook->id);
+            }
+        }
+
+        $meta = $paper->meta ?? [];
+        $catalogIds = $meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? []);
+        $catalogIds = $this->normalizeCatalogNodeIds($catalogIds);
+        if (!empty($catalogIds)) {
+            $titles = TextbookCatalog::query()
+                ->whereIn('id', $catalogIds)
+                ->pluck('title')
+                ->filter()
+                ->take(6)
+                ->toArray();
+            if (!empty($titles)) {
+                $lines[] = '目录: ' . implode(' / ', $titles);
+            }
+        }
+
+        return empty($lines) ? null : implode("\n", $lines);
+    }
+
+    private function buildGenerationContext(?\App\Models\SourcePaper $paper): ?string
+    {
+        return $this->buildKnowledgeMatchContext($paper);
+    }
+
+    private function buildKnowledgePointPool(?\App\Models\SourcePaper $paper): array
+    {
+        $query = KnowledgePoint::query()->select(['kp_code', 'name']);
+        if ($paper?->grade) {
+            $query->where('grade', $paper->grade);
+        }
+        if ($paper?->subject) {
+            $query->where('subject', $paper->subject);
+        }
+
+        return $query->orderBy('kp_code')->limit(300)->get()->toArray();
+    }
+
+    private function resolveSolutionSteps(PreQuestionCandidate $candidate, string $questionType, array $meta): array
+    {
+        if ($questionType !== 'answer') {
+            return [
+                'solution' => $meta['solution'] ?? '',
+                'steps' => [],
+            ];
+        }
+
+        if (!empty($meta['solution_steps'])) {
+            return [
+                'solution' => $meta['solution'] ?? '',
+                'steps' => $meta['solution_steps'],
+            ];
+        }
+
+        $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown);
+        if ($questionText === '') {
+            return [
+                'solution' => $meta['solution'] ?? '',
+                'steps' => [],
+            ];
+        }
+
+        $result = app(AiSolutionService::class)->generateSolutionSteps($questionText);
+        return [
+            'solution' => $result['solution'] ?? '',
+            'steps' => $result['steps'] ?? [],
+        ];
+    }
+
+    private function normalizeCatalogNodeIds(array|string $value): array
+    {
+        $ids = is_array($value) ? $value : [$value];
+        $ids = array_values(array_unique(array_map('intval', array_filter($ids))));
+        return $ids;
+    }
+}

+ 31 - 32
app/Services/QuestionExtractorService.php

@@ -32,6 +32,8 @@ class QuestionExtractorService
                 $order = $i + 1;
                 $raw = $block['raw_markdown'];
                 $clean = $this->cleanMarkdown($raw);
+                $rawHash = $this->hashContent($raw);
+                $cleanHash = $this->hashContent($clean);
                 $sequence = $sequenceStart++;
                 $reuse = $this->findReusableCandidate($raw, $clean);
                 $reuseConfidence = $reuse?->confidence ?? $reuse?->ai_confidence;
@@ -50,7 +52,9 @@ class QuestionExtractorService
                         'order' => $order,
                         'sequence' => $sequence,
                         'raw_markdown' => $raw,
+                        'raw_hash' => $rawHash,
                         'clean_markdown' => $clean,
+                        'clean_hash' => $cleanHash,
                         'structured_json' => $reuse?->structured_json,
                         'stem' => $reuse?->stem,
                         'options' => $reuse?->options,
@@ -92,36 +96,6 @@ class QuestionExtractorService
         });
     }
 
-    /**
-     * 严格按题号拆分,题号正则:^\s*(\d+)(\.|、|\)|)|\]|】)?\s+
-     */
-    public function splitQuestions(string $markdown): array
-    {
-        $pattern = '/^\s*(\d+)(?:[\\.、\\))\\]】])?\s+/m';
-        preg_match_all($pattern, $markdown, $matches, PREG_OFFSET_CAPTURE);
-
-        if (empty($matches[0])) {
-            return [[
-                'question_number' => 1,
-                'raw_markdown' => trim($markdown),
-            ]];
-        }
-
-        $blocks = [];
-        $positions = array_map(fn($m) => $m[1], $matches[0]);
-        $numbers = array_map(fn($m) => $m[0], $matches[1]);
-
-        foreach ($positions as $idx => $start) {
-            $end = $positions[$idx + 1] ?? strlen($markdown);
-            $slice = substr($markdown, $start, $end - $start);
-            $blocks[] = [
-                'question_number' => $numbers[$idx] ?? ($idx + 1),
-                'raw_markdown' => trim($slice),
-            ];
-        }
-
-        return $blocks;
-    }
 
     protected function detectFormula(string $markdown): bool
     {
@@ -133,19 +107,44 @@ class QuestionExtractorService
         return trim(Str::of($markdown)->replace("\r", '')->toString());
     }
 
+    protected function hashContent(?string $content): ?string
+    {
+        $content = trim((string) $content);
+        if ($content === '') {
+            return null;
+        }
+
+        return hash('sha1', $content);
+    }
+
     /**
      * 查找可复用的高置信度候选题,避免重复 AI 解析。
      */
     protected function findReusableCandidate(string $raw, string $clean): ?PreQuestionCandidate
     {
+        $rawHash = $this->hashContent($raw);
+        $cleanHash = $this->hashContent($clean);
+
         return PreQuestionCandidate::query()
-            ->where(function ($query) use ($raw, $clean) {
+            ->where(function ($query) use ($raw, $clean, $rawHash, $cleanHash) {
                 $query->where('raw_markdown', $raw)
                     ->orWhere('clean_markdown', $clean);
+
+                if ($rawHash || $cleanHash) {
+                    $query->orWhere(function ($inner) use ($rawHash, $cleanHash) {
+                        if ($rawHash) {
+                            $inner->where('raw_hash', $rawHash);
+                        }
+                        if ($cleanHash) {
+                            $inner->orWhere('clean_hash', $cleanHash);
+                        }
+                    });
+                }
             })
             ->where(function ($query) {
                 $query->where('confidence', '>=', 0.85)
-                    ->orWhere('ai_confidence', '>=', 0.85);
+                    ->orWhere('ai_confidence', '>=', 0.85)
+                    ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.ai_parsed')) = 'true'");
             })
             ->orderByDesc('confidence')
             ->orderByDesc('ai_confidence')

+ 1 - 0
app/Services/QuestionGenerationService.php

@@ -40,6 +40,7 @@ class QuestionGenerationService
             'difficulty' => $payload['difficulty'] ?? null,
             'knowledge_points' => $payload['knowledge_points'] ?? [],
             'solution_steps' => $payload['solution_steps'] ?? [],
+            'abilities' => $payload['abilities'] ?? [],
         ];
     }
 }

+ 19 - 4
app/Services/QuestionImportService.php

@@ -75,16 +75,21 @@ class QuestionImportService
         $parser = app(MarkdownQuestionParser::class);
         $created = 0;
 
-        foreach ($blocks as $block) {
-            $candidate = $parser->parseRawMarkdown((string) ($block['raw_markdown'] ?? ''), (int) ($block['index'] ?? 0));
+            foreach ($blocks as $block) {
+                $candidate = $parser->parseRawMarkdown((string) ($block['raw_markdown'] ?? ''), (int) ($block['index'] ?? 0));
+                $raw = (string) ($candidate['raw_markdown'] ?? '');
+                $clean = trim(str_replace("\r", '', $raw));
 
             PreQuestionCandidate::create([
                 'import_id' => $importId,
                 'source_file_id' => $sourceFileId,
                 'order_index' => (int) ($block['sequence'] ?? 0),
                 'index' => (int) ($block['index'] ?? 0),
-                'raw_markdown' => $candidate['raw_markdown'] ?? '',
-                'raw_text' => strip_tags((string) ($candidate['raw_markdown'] ?? '')),
+                'raw_markdown' => $raw,
+                'raw_hash' => $this->hashContent($raw),
+                'raw_text' => strip_tags($raw),
+                'clean_markdown' => $clean,
+                'clean_hash' => $this->hashContent($clean),
                 'stem' => $candidate['stem'] ?? null,
                 'options' => $candidate['options'] ?? null,
                 'images' => $candidate['images'] ?? [],
@@ -117,4 +122,14 @@ class QuestionImportService
 
         return $created;
     }
+
+    private function hashContent(?string $content): ?string
+    {
+        $content = trim((string) $content);
+        if ($content === '') {
+            return null;
+        }
+
+        return hash('sha1', $content);
+    }
 }

+ 3 - 3
app/Services/QuestionLocalService.php

@@ -419,11 +419,11 @@ class QuestionLocalService
 
     private function mapQuestionTypeLabel(string $type): string
     {
-        return match ($type) {
+        return match (strtoupper($type)) {
             'CHOICE' => '选择题',
             'MULTIPLE_CHOICE' => '多选题',
-            'FILL_IN_THE_BLANK' => '填空题',
-            'CALCULATION', 'WORD_PROBLEM' => '解答题',
+            'FILL_IN_THE_BLANK', 'FILL' => '填空题',
+            'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题',
             'PROOF' => '证明题',
             default => '其他',
         };

+ 35 - 4
app/Services/QuestionPromptService.php

@@ -4,7 +4,7 @@ namespace App\Services;
 
 class QuestionPromptService
 {
-    public function buildKnowledgeMatchPrompt(string $questionText, array $candidates): string
+    public function buildKnowledgeMatchPrompt(string $questionText, array $candidates, ?string $context = null): string
     {
         $candidateLines = array_map(
             fn ($item) => ($item['kp_code'] ?? '') . ' - ' . ($item['name'] ?? ''),
@@ -23,6 +23,9 @@ class QuestionPromptService
 - 输出字段为 knowledge_points 数组,每个元素包含 kp_code 与 weight (0~1)
 - 若无法匹配,返回空数组
 
+辅助信息(可能包含教材目录):
+{context}
+
 题目内容:
 {question}
 
@@ -40,8 +43,12 @@ PROMPT;
         }
 
         return str_replace(
-            ['{question}', '{candidates}'],
-            [$questionText, $candidateText],
+            ['{question}', '{candidates}', '{context}'],
+            [
+                $context ? ("{$questionText}\n\n上下文提示:\n{$context}") : $questionText,
+                $candidateText,
+                $context ?? '',
+            ],
             $template
         );
     }
@@ -81,7 +88,10 @@ PROMPT;
 
     public function buildQuestionFromSourcePrompt(string $sourceText): string
     {
-        $template = config('ai.question_generation_prompt');
+        $promptService = app(PromptService::class);
+        $promptService->listPrompts(type: 'question_enrich', active: 'yes');
+        $template = $promptService->getPromptContent('question_enrich_default')
+            ?: config('ai.question_generation_prompt');
         if (!$template) {
             $template = <<<PROMPT
 你是一名“数学题目生成器”。请根据给定材料生成可入库的题目 JSON。
@@ -113,4 +123,25 @@ PROMPT;
 
         return str_replace('{content}', $sourceText, $template);
     }
+
+    public function buildSolutionRegenPrompt(string $stem, string $answer, array $images = []): string
+    {
+        $promptService = app(PromptService::class);
+        $promptService->listPrompts(type: 'question_solution_regen', active: 'yes');
+        $template = $promptService->getPromptContent('question_solution_regen_default');
+        if (!$template) {
+            $template = "请根据题干与答案生成解题思路,输出 JSON,包含 solution 与 steps。";
+        }
+
+        $imageText = '';
+        if (!empty($images)) {
+            $imageText = implode("\n", array_map('strval', $images));
+        }
+
+        return str_replace(
+            ['{stem}', '{provided_answer}', '{image_urls}'],
+            [$stem, $answer, $imageText],
+            $template
+        );
+    }
 }

+ 150 - 4
app/Services/SourcePaperExtractorService.php

@@ -55,10 +55,20 @@ class SourcePaperExtractorService
         $segments = [];
         $current = ['title' => null, 'buffer' => []];
 
-        $paperPattern = '/^(#{1,2})\s*(.+卷|期中|期末|专项|模拟|基础卷|提升卷|练习卷)/u';
+        $headingPattern = '/^(#{1,2})\s*(.+)$/u';
+        $paperKeywords = '/(期中|期末|专项|模拟|基础卷|提升卷|练习卷|单元卷|测试卷|套卷|试卷)/u';
+        $sectionPrefix = '/^(卷\\s*[一二三四五六七八九十0-9IVX]+|第\\s*[一二三四五六七八九十0-9IVX]+\\s*卷)/u';
+        $chapterPaperPattern = '/^(第\\s*[一二三四五六七八九十0-9]+\\s*[章节单元]).*(质量检测卷|能力提优检测卷|基础过关检测卷|检测卷|训练卷|专项训练卷)/u';
+        $paperLinePattern = '/(质量检测卷|能力提优检测卷|基础过关检测卷|检测卷|训练卷|专项训练卷|期中|期末|专项|模拟|基础卷|提升卷|练习卷|单元卷|测试卷|套卷|试卷)/u';
+        $questionLinePattern = '/^\\s*(\\d+|[A-D])\\s*[\\.、\\)]/u';
+        $excludeKeywords = '/(答题卡|参考答案|扫描全能王|解析|来源)/u';
+        $commentPattern = '/<!--\s*paper:\s*(.+?)\s*-->/i';
 
         foreach ($lines as $line) {
-            if (preg_match($paperPattern, $line, $m)) {
+            $trimmed = trim($line);
+
+            // 优先支持隐藏的卷子标记
+            if (preg_match($commentPattern, $trimmed, $cm)) {
                 if (!empty($current['buffer'])) {
                     $segments[] = [
                         'title' => $current['title'],
@@ -68,7 +78,78 @@ class SourcePaperExtractorService
                     ];
                 }
                 $current = [
-                    'title' => trim($m[2]),
+                    'title' => trim($cm[1]),
+                    'buffer' => [$line],
+                ];
+                continue;
+            }
+
+            if ($trimmed !== '' && !preg_match($headingPattern, $trimmed)) {
+                $isSectionPrefix = preg_match($sectionPrefix, $trimmed) === 1;
+                $isPartHeading = preg_match('/^(选择题|填空题|解答题|综合题|计算题|应用题)/u', $trimmed) === 1;
+                $isChapterPaper = preg_match($chapterPaperPattern, $trimmed) === 1;
+                $isPaperLine = $isChapterPaper || preg_match($paperLinePattern, $trimmed) === 1;
+                $isQuestionLine = preg_match($questionLinePattern, $trimmed) === 1;
+                $lineLength = mb_strlen($trimmed);
+
+                if ($isPaperLine && !$isSectionPrefix && !$isPartHeading && !$isQuestionLine) {
+                    if (preg_match($excludeKeywords, $trimmed)) {
+                        $current['buffer'][] = $line;
+                        continue;
+                    }
+
+                    if (!$isChapterPaper && $lineLength > 80) {
+                        $current['buffer'][] = $line;
+                        continue;
+                    }
+
+                    if ($this->isSameTitle($current['title'], $trimmed)) {
+                        $current['buffer'][] = $line;
+                        continue;
+                    }
+
+                    if (!empty($current['buffer'])) {
+                        $segments[] = [
+                            'title' => $current['title'],
+                            'full_title' => $current['title'],
+                            'raw' => trim(implode("\n", $current['buffer'])),
+                            'meta' => $this->detectMetaFromTitle($current['title']),
+                        ];
+                    }
+                    $current = [
+                        'title' => $this->sanitizeTitle($trimmed),
+                        'buffer' => [$line],
+                    ];
+                    continue;
+                }
+            }
+
+            if (preg_match($headingPattern, $line, $m)) {
+                $title = $this->sanitizeTitle(trim($m[2]));
+                $isSectionPrefix = preg_match($sectionPrefix, $title) === 1;
+                $isPaper = preg_match($paperKeywords, $title) === 1;
+                $isPaper = $isPaper || (str_contains($title, '卷') && !$isSectionPrefix);
+
+                if (!$isPaper) {
+                    $current['buffer'][] = $line;
+                    continue;
+                }
+
+                if ($this->isSameTitle($current['title'], $title)) {
+                    $current['buffer'][] = $line;
+                    continue;
+                }
+
+                if (!empty($current['buffer'])) {
+                    $segments[] = [
+                        'title' => $current['title'],
+                        'full_title' => $current['title'],
+                        'raw' => trim(implode("\n", $current['buffer'])),
+                        'meta' => $this->detectMetaFromTitle($current['title']),
+                    ];
+                }
+                $current = [
+                    'title' => $title,
                     'buffer' => [$line],
                 ];
             } else {
@@ -94,7 +175,72 @@ class SourcePaperExtractorService
             ]];
         }
 
-        return $segments;
+        $segments = $this->mergeAdjacentSegments($segments);
+
+        return array_values(array_filter($segments, function ($segment) {
+            $title = trim((string) ($segment['title'] ?? ''));
+            $raw = trim((string) ($segment['raw'] ?? ''));
+            if ($title === '' && mb_strlen($raw) < 80) {
+                return false;
+            }
+            return true;
+        }));
+    }
+
+    protected function sanitizeTitle(string $title): string
+    {
+        $title = trim($title);
+        $title = preg_replace('/^[◎◆•·\\*\\-\\s]+/u', '', $title);
+        $title = preg_replace('/^[①②③④⑤⑥⑦⑧⑨⑩\\d]+[\\s\\.、]+/u', '', $title);
+        $title = preg_replace('/\\s*\\d+\\s*\\/\\s*答\\s*\\d+$/u', '', $title);
+        $title = trim($title);
+
+        if (mb_strlen($title) > 200) {
+            $title = mb_substr($title, 0, 200);
+        }
+
+        return $title;
+    }
+
+    protected function isSameTitle(?string $currentTitle, ?string $nextTitle): bool
+    {
+        $currentTitle = $currentTitle ? $this->sanitizeTitle($currentTitle) : null;
+        $nextTitle = $nextTitle ? $this->sanitizeTitle($nextTitle) : null;
+
+        return $currentTitle !== null && $nextTitle !== null && $currentTitle === $nextTitle;
+    }
+
+    protected function mergeAdjacentSegments(array $segments): array
+    {
+        $merged = [];
+        foreach ($segments as $segment) {
+            $title = $segment['title'] ?? null;
+            $raw = $segment['raw'] ?? '';
+            $lastIndex = count($merged) - 1;
+            
+            if ($lastIndex >= 0) {
+                // 1. 同名合并
+                if ($this->isSameTitle($merged[$lastIndex]['title'] ?? null, $title)) {
+                    $merged[$lastIndex]['raw'] = trim($merged[$lastIndex]['raw'] . "\n\n" . $raw);
+                    continue;
+                }
+                
+                // 2. 碎片合并:当前片段无标题,且长度较短(归纳为前一个卷子的尾部或干扰项)
+                if (empty($title) && mb_strlen(trim($raw)) < 500) {
+                    $merged[$lastIndex]['raw'] = trim($merged[$lastIndex]['raw'] . "\n\n" . $raw);
+                    continue;
+                }
+
+                // 3. 碎片合并:当前片段标题太短且不含核心关键词,且其 Markdown 内容也不长
+                if ($title && mb_strlen($title) < 5 && mb_strlen(trim($raw)) < 300) {
+                     $merged[$lastIndex]['raw'] = trim($merged[$lastIndex]['raw'] . "\n\n" . $raw);
+                     continue;
+                }
+            }
+            $merged[] = $segment;
+        }
+
+        return $merged;
     }
 
     protected function detectMetaFromTitle(?string $title): array

+ 42 - 1
app/Services/StudentAnswerAnalysisService.php

@@ -15,7 +15,8 @@ use Illuminate\Support\Str;
 class StudentAnswerAnalysisService
 {
     public function __construct(
-        private readonly LocalAIAnalysisService $aiAnalysisService
+        private readonly LocalAIAnalysisService $aiAnalysisService,
+        private readonly ExamAnswerAnalysisService $examAnswerAnalysisService
     ) {}
 
     /**
@@ -345,4 +346,44 @@ class StudentAnswerAnalysisService
             return [];
         }
     }
+
+    /**
+     * 使用增强版步骤级分析算法
+     *
+     * 这是基于《卷子分析思考.md》思路的增强版分析方法
+     * 支持步骤级分析、知识点映射和智能出卷推荐
+     *
+     * @param array $examData 考试数据
+     * @return array 分析结果
+     */
+    public function analyzeWithEnhancedSteps(array $examData): array
+    {
+        Log::info('StudentAnswerAnalysisService: 开始增强版步骤级分析', [
+            'exam_id' => $examData['exam_id'] ?? 'unknown',
+            'student_id' => $examData['student_id'] ?? 'unknown',
+            'has_steps' => !empty(array_filter($examData['questions'] ?? [], fn($q) => !empty($q['steps'])))
+        ]);
+
+        try {
+            // 使用增强的分析算法
+            $result = $this->examAnswerAnalysisService->analyzeExamAnswers($examData);
+
+            Log::info('StudentAnswerAnalysisService: 增强版步骤级分析完成', [
+                'exam_id' => $examData['exam_id'],
+                'student_id' => $examData['student_id'],
+                'analyzed_knowledge_points' => count($result['knowledge_point_analysis'] ?? [])
+            ]);
+
+            return $result;
+
+        } catch (\Exception $e) {
+            Log::error('StudentAnswerAnalysisService: 增强版步骤级分析失败', [
+                'exam_id' => $examData['exam_id'] ?? 'unknown',
+                'student_id' => $examData['student_id'] ?? 'unknown',
+                'error' => $e->getMessage()
+            ]);
+
+            throw new \Exception('增强版步骤级分析失败:' . $e->getMessage());
+        }
+    }
 }

+ 12 - 0
check_student_ids.php

@@ -0,0 +1,12 @@
+<?php
+require __DIR__ . '/bootstrap/app.php';
+
+$app = require_once __DIR__ . '/bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
+
+$studentIds = DB::connection('mysql')->table('students')->limit(5)->get(['student_id', 'name']);
+
+echo "学生ID类型检查:\n";
+foreach ($studentIds as $student) {
+    echo "ID: {$student->student_id}, Type: " . gettype($student->student_id) . ", Name: {$student->name}\n";
+}

+ 10 - 5
config/ai.php

@@ -122,15 +122,19 @@ PROMPT,
 PROMPT,
 
     'question_generation_prompt' => <<<'PROMPT'
-你是一名“数学题目生成器”。请根据给定材料生成可入库的题目 JSON
+你是一名“数学题目完善助手”。给定原题干与可选图片外链,请补全题目关键信息并做轻量修订
 
 要求:
 - 只输出 JSON
 - 必须包含字段:stem, options, answer, solution, question_type, difficulty, knowledge_points, solution_steps
-- short/answer 类题目必须提供 solution_steps(分步评分),每步包含 score 与 kp_codes
+- 可选字段:abilities(能力/技能点数组)
+- stem 只做轻微修订(排版/符号/错别字),不得改题意
+- 若提供图片外链(image_urls),可结合图片理解题意,但不要生成 SVG
+- question_type 仅允许:choice / fill / answer
+- answer 类题目必须提供 solution_steps(分步评分),每步包含 score 与 kp_codes
 - knowledge_points 为题目级知识点列表
 
-材料内容:
+材料内容(含题干与图片)
 {content}
 
 输出 JSON 示例:
@@ -139,12 +143,13 @@ PROMPT,
   "options": {"A": "...", "B": "..."},
   "answer": "...",
   "solution": "...",
-  "question_type": "short",
+  "question_type": "answer",
   "difficulty": 0.6,
   "knowledge_points": ["A01"],
   "solution_steps": [
     {"step_index": 1, "title": "...", "content": "...", "score": 4, "kp_codes": ["A01"]}
-  ]
+  ],
+  "abilities": ["计算能力", "分析能力"]
 }
 PROMPT,
 ];

+ 1 - 1
database/factories/QuestionFactory.php

@@ -13,7 +13,7 @@ class QuestionFactory extends Factory
     {
         return [
             'question_code' => 'Q-' . $this->faker->unique()->numerify('######'),
-            'question_type' => 'short',
+            'question_type' => 'answer',
             'stem' => $this->faker->sentence(),
             'answer' => $this->faker->sentence(),
             'solution' => $this->faker->paragraph(),

+ 462 - 250
resources/views/filament/pages/markdown-import-workbench.blade.php

@@ -1,4 +1,41 @@
 <x-filament::page>
+    <style>
+        .command-bar {
+            background: rgba(255, 255, 255, 0.85);
+            backdrop-filter: blur(10px);
+            -webkit-backdrop-filter: blur(10px);
+            border: 1px solid rgba(255, 255, 255, 0.3);
+            box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+        }
+        .ui-tag {
+            background: #f1f5f9;
+            color: #475569;
+            padding: 2px 8px;
+            border-radius: 6px;
+            font-size: 0.75rem;
+            border: 1px solid #e2e8f0;
+        }
+        .custom-scroll::-webkit-scrollbar {
+            width: 4px;
+        }
+        .custom-scroll::-webkit-scrollbar-track {
+            background: transparent;
+        }
+        .custom-scroll::-webkit-scrollbar-thumb {
+            background: #e2e8f0;
+            border-radius: 10px;
+        }
+        .custom-scroll::-webkit-scrollbar-thumb:hover {
+            background: #cbd5e1;
+        }
+        .btn-hover-zoom {
+            transition: transform 0.2s;
+        }
+        .btn-hover-zoom:hover {
+            transform: scale(1.02);
+        }
+    </style>
+
     <div class="space-y-4">
         @if(!$this->importRecord())
             <x-filament::section>
@@ -11,314 +48,489 @@
         @elseif(!$this->filenameValid)
             <x-filament::section>
                 <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
-                    文件名解析失败:{{ $this->filenameWarning }}
+                    <div class="flex items-center gap-2 font-bold mb-1">
+                        <x-heroicon-o-exclamation-triangle class="w-5 h-5" />
+                        文件名解析失败
+                    </div>
+                    {{ $this->filenameWarning }}
                 </div>
                 <div class="mt-3 text-sm text-slate-600">
                     请在导入列表中修改文件名并重新导入后再进入工作台。
                 </div>
             </x-filament::section>
         @else
-        <div class="flex flex-wrap items-center gap-3">
-            <x-filament::input.wrapper class="w-64">
-                <x-filament::input wire:model.debounce.400ms="search" placeholder="搜索卷子标题/编码" />
-            </x-filament::input.wrapper>
+        
+        {{-- 顶部高级指令中心 --}}
+        <div class="sticky top-0 z-20 -mx-4 px-4 py-3 mb-6 command-bar rounded-xl flex items-center justify-between gap-4">
+            <div class="flex items-center gap-4 flex-1">
+                <x-filament::input.wrapper class="w-72">
+                    <x-filament::input 
+                        wire:model.debounce.400ms="search" 
+                        placeholder="搜索卷子标题/编码..." 
+                        prefix-icon="heroicon-m-magnifying-glass"
+                    />
+                </x-filament::input.wrapper>
+                
+                <div class="h-6 w-px bg-slate-200"></div>
+
+                <div class="flex items-center gap-2">
+                    <x-filament::button 
+                        color="gray" 
+                        icon="heroicon-o-sparkles" 
+                        wire:click="autoInfer"
+                        tooltip="基于文件名和内容智能推断缺失信息"
+                        class="btn-hover-zoom"
+                    >
+                        自动推断
+                    </x-filament::button>
+                    
+                    <x-filament::button 
+                        color="gray" 
+                        icon="heroicon-o-document-duplicate" 
+                        wire:click="autoInferSelected"
+                        tooltip="对勾选卷子执行智能推断"
+                        class="btn-hover-zoom"
+                    >
+                        批量推断
+                    </x-filament::button>
+                </div>
+            </div>
 
-            <x-filament::input.wrapper class="w-44">
-                <x-filament::input.select wire:model="groupBy">
-                    <option value="bundle">按套卷分组</option>
-                    <option value="paper">按卷子分组</option>
-                    <option value="grade">按年级分组</option>
-                </x-filament::input.select>
-            </x-filament::input.wrapper>
+            <div class="flex items-center gap-3">
+                <x-filament::button 
+                    color="warning" 
+                    icon="heroicon-o-squares-plus" 
+                    wire:click="mergeSelectedPapers"
+                    class="btn-hover-zoom"
+                >
+                    合并选定卷子
+                </x-filament::button>
 
-            <x-filament::button color="gray" wire:click="autoInfer">自动推断</x-filament::button>
-            <x-filament::button color="gray" wire:click="autoInferSelected">批量推断</x-filament::button>
-            <x-filament::button color="gray" wire:click="autoBundleKey">生成套卷标识</x-filament::button>
-            <x-filament::button color="gray" wire:click="autoBundleKeySelected">批量生成套卷标识</x-filament::button>
-            <x-filament::button color="primary" wire:click="savePaper">保存当前</x-filament::button>
-            <x-filament::button color="gray" wire:click="$set('dense', ! $wire.dense)">密度切换</x-filament::button>
+                <x-filament::button 
+                    color="primary" 
+                    icon="heroicon-o-check-circle" 
+                    wire:click="savePaper"
+                    size="lg"
+                    class="btn-hover-zoom shadow-sm"
+                >
+                    保存当前设置
+                </x-filament::button>
+            </div>
         </div>
 
-        <div class="grid grid-cols-12 gap-6">
-            <div class="col-span-8 space-y-4">
-                <x-filament::section heading="导入信息">
-                    <div class="grid grid-cols-3 gap-4 text-sm text-slate-600">
-                        <div class="rounded-lg border border-slate-200 p-3">
-                            <div class="text-xs text-slate-500">导入文件</div>
-                            <div class="font-medium text-slate-800">{{ $this->importRecord()?->file_name ?? '未选择导入记录' }}</div>
-                        </div>
-                        <div class="rounded-lg border border-slate-200 p-3">
-                            <div class="text-xs text-slate-500">解析状态</div>
-                            <div class="font-medium text-slate-800">{{ $this->importRecord()?->status_label ?? '—' }}</div>
-                        </div>
-                        <div class="rounded-lg border border-slate-200 p-3">
-                            <div class="text-xs text-slate-500">候选题数</div>
-                            <div class="font-medium text-slate-800">{{ $this->importRecord()?->parsed_count ?? 0 }}</div>
-                        </div>
+        <div class="grid grid-cols-12 gap-6 items-start">
+            {{-- 左侧:资产与资源 --}}
+            <div class="col-span-12 lg:col-span-8 space-y-6">
+                {{-- 导入背景卡 --}}
+                <div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
+                    <div class="px-4 py-3 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
+                        <h3 class="font-bold text-slate-800 flex items-center gap-2">
+                            <x-heroicon-o-cloud-arrow-down class="w-4 h-4 text-primary-500" />
+                            导入源信息
+                        </h3>
+                        <span class="text-xs font-mono text-slate-400">ID: #{{ $this->importId }}</span>
                     </div>
-                    @if(!empty($this->filenameParsed))
-                        <div class="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">
-                            <span class="ui-tag">系列:{{ $this->filenameParsed['series'] ?? '-' }}</span>
-                            <span class="ui-tag">年级:{{ $this->filenameParsed['grade'] ?? '-' }}</span>
-                            <span class="ui-tag">学期:{{ $this->filenameParsed['term'] ?? '-' }}</span>
-                            <span class="ui-tag">学科:{{ $this->filenameParsed['subject'] ?? '-' }}</span>
-                            <span class="ui-tag">名称:{{ $this->filenameParsed['name'] ?? '-' }}</span>
+                    <div class="p-4">
+                        <div class="grid grid-cols-3 gap-6">
+                            <div class="space-y-1">
+                                <div class="text-[10px] uppercase tracking-wider font-bold text-slate-400">原始文件</div>
+                                <div class="text-sm font-medium text-slate-700 truncate" title="{{ $this->importRecord()?->file_name }}">
+                                    {{ $this->importRecord()?->file_name ?? '-' }}
+                                </div>
+                            </div>
+                            <div class="space-y-1">
+                                <div class="text-[10px] uppercase tracking-wider font-bold text-slate-400">当前状态</div>
+                                <div>
+                                    <x-filament::badge color="info" icon="heroicon-m-arrow-path">
+                                        {{ $this->importRecord()?->status_label ?? '未知' }}
+                                    </x-filament::badge>
+                                </div>
+                            </div>
+                            <div class="space-y-1 text-right">
+                                <div class="text-[10px] uppercase tracking-wider font-bold text-slate-400">总候选题</div>
+                                <div class="text-xl font-black text-primary-600">{{ $this->importRecord()?->parsed_count ?? 0 }}</div>
+                            </div>
                         </div>
-                    @endif
-                </x-filament::section>
-
-                <x-filament::section heading="卷子列表(选择后批量覆盖)">
-                    <div class="mb-2 flex flex-wrap gap-2">
-                        <x-filament::button color="gray" wire:click="selectAllVisible">全选当前列表</x-filament::button>
-                        <x-filament::button color="gray" wire:click="clearSelection">清空选择</x-filament::button>
+                        
+                        @if(!empty($this->filenameParsed))
+                            <div class="mt-4 pt-4 border-t border-slate-50 flex flex-wrap gap-2 text-xs text-slate-500">
+                                <span class="ui-tag">系列:{{ $this->filenameParsed['series'] ?? '-' }}</span>
+                                <span class="ui-tag">年级:{{ $this->filenameParsed['grade'] ?? '-' }}</span>
+                                <span class="ui-tag">学期:{{ $this->filenameParsed['term'] ?? '-' }}</span>
+                                <span class="ui-tag">学科:{{ $this->filenameParsed['subject'] ?? '-' }}</span>
+                                <span class="ui-tag">名称:{{ $this->filenameParsed['name'] ?? '-' }}</span>
+                            </div>
+                        @endif
                     </div>
+                </div>
+
+                {{-- 卷子切分列表 --}}
+                <x-filament::section>
+                    <x-slot name="heading">
+                        <div class="flex items-center gap-2">
+                            <x-heroicon-o-list-bullet class="w-5 h-5 text-warning-500" />
+                            待核对卷子列表
+                        </div>
+                    </x-slot>
+                    <x-slot name="headerEnd">
+                        <div class="flex items-center gap-2">
+                            <x-filament::link wire:click="selectAllVisible" class="text-xs cursor-pointer">全选当前</x-filament::link>
+                            <span class="text-slate-200">|</span>
+                            <x-filament::link wire:click="clearSelection" color="danger" class="text-xs cursor-pointer">清空选择</x-filament::link>
+                        </div>
+                    </x-slot>
 
-                    <div class="max-h-72 overflow-y-auto divide-y divide-gray-100">
+                    <div class="overflow-y-auto custom-scroll pr-2 divide-y divide-slate-50 last:border-b-0" style="height: 500px;">
                         @foreach($this->groupedPapers() as $group => $items)
-                            <div class="px-3 py-2 text-xs font-semibold text-slate-500 bg-slate-50">{{ $group }}</div>
+                            <div class="sticky top-0 z-10 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400 bg-white/95 backdrop-blur shadow-sm my-2 rounded-md">
+                                {{ $group }}
+                            </div>
                             @foreach($items as $paper)
                                 @php
                                     $meta = $paper['meta'] ?? [];
                                     $expected = $meta['expected_count'] ?? null;
                                     $candidateCount = $paper['candidates_count'] ?? 0;
+                                    $isSelected = ((int)$this->selectedPaperId === (int)$paper['id']);
+                                    $catalogTitles = $this->catalogTitlesForPaper($paper);
                                 @endphp
-                                <label class="flex items-start gap-3 {{ $dense ? 'py-1' : 'py-2' }}">
-                                    <input type="checkbox" wire:model="selectedIds" value="{{ $paper['id'] }}" class="mt-1 rounded border-gray-300">
-                                    <button type="button" wire:click="selectPaper({{ $paper['id'] }})" class="text-left flex-1">
-                                        <div class="text-sm font-medium text-gray-900">{{ $paper['title'] ?? $paper['full_title'] ?? '未命名' }}</div>
-                                        <div class="text-xs text-gray-500 flex flex-wrap gap-2 items-center">
-                                            <span>年级 {{ $paper['grade'] ?? '-' }} · 学期 {{ $paper['term'] ?? '-' }}</span>
-                                            <span>候选题数 {{ $candidateCount }}</span>
-                                            @if($expected)
-                                                <span class="{{ ((int) $expected) === (int) $candidateCount ? 'text-emerald-700 bg-emerald-50' : 'text-amber-700 bg-amber-50' }} px-2 py-0.5 rounded">
-                                                    预期 {{ $expected }}
-                                                </span>
+                                <div @class([
+                                    'group flex items-start gap-3 p-3 transition-all rounded-xl border border-transparent',
+                                    'bg-primary-50/40 border-primary-100 shadow-sm' => $isSelected,
+                                    'hover:bg-slate-50/80 hover:border-slate-100' => !$isSelected,
+                                ])>
+                                    <input type="checkbox" wire:model="selectedIds" wire:click="selectPaper({{ $paper['id'] }})" value="{{ $paper['id'] }}" class="mt-1.5 rounded border-slate-300 text-primary-600 focus:ring-primary-500">
+                                    
+                                    <button type="button" wire:click="selectPaper({{ $paper['id'] }})" class="text-left flex-1 min-w-0">
+                                        <div class="flex items-center justify-between gap-2 mb-1">
+                                            <span @class([
+                                                'text-sm font-bold truncate transition-colors',
+                                                'text-primary-700' => $isSelected,
+                                                'text-slate-800' => !$isSelected,
+                                            ])>
+                                                {{ $paper['title'] ?? $paper['full_title'] ?? '未命名卷子' }}
+                                            </span>
+                                            @if($isSelected)
+                                                <span class="inline-flex h-2 w-2 rounded-full bg-primary-500 animate-pulse"></span>
                                             @endif
+                                        </div>
+                                        
+                                        <div class="flex flex-wrap items-center gap-3 text-[11px]">
+                                            <div class="flex items-center gap-1 text-slate-500 font-medium">
+                                                <x-heroicon-m-academic-cap class="w-3.5 h-3.5" />
+                                                {{ $paper['grade'] ?? '-' }}年级 / {{ $paper['term'] ?? '-' }}
+                                            </div>
+                                            
+                                            <div @class([
+                                                'px-2 py-0.5 rounded-full font-bold border',
+                                                'bg-emerald-50 text-emerald-700 border-emerald-100' => $candidateCount > 0,
+                                                'bg-slate-50 text-slate-400 border-slate-100' => $candidateCount === 0,
+                                            ])>
+                                                题量: {{ $candidateCount }}
+                                                @if($expected)
+                                                    <span class="text-[9px] opacity-70 ml-1"> (预期 {{ $expected }})</span>
+                                                @endif
+                                            </div>
+
                                             @if(empty($paper['textbook_id']))
-                                                <span class="px-2 py-0.5 rounded bg-rose-50 text-rose-700">未关联教材</span>
+                                                <span class="text-rose-500 font-bold flex items-center gap-0.5">
+                                                    <x-heroicon-m-x-circle class="w-3.5 h-3.5" /> 缺教材
+                                                </span>
                                             @endif
-                                            @if(empty($meta['catalog_node_id'] ?? null))
-                                                <span class="px-2 py-0.5 rounded bg-rose-50 text-rose-700">未关联目录</span>
+                                            @if(empty($catalogTitles))
+                                                <span class="text-rose-500 font-bold flex items-center gap-0.5">
+                                                    <x-heroicon-m-x-circle class="w-3.5 h-3.5" /> 缺目录
+                                                </span>
                                             @endif
                                         </div>
+
+                                        @if(!empty($catalogTitles))
+                                            <div class="mt-1 text-[11px] text-slate-500 font-medium">
+                                                目录:{{ implode(' · ', array_slice($catalogTitles, 0, 3)) }}@if(count($catalogTitles) > 3)…@endif
+                                            </div>
+                                        @endif
                                     </button>
-                                </label>
+                                </div>
                             @endforeach
                         @endforeach
-                        @if($this->papers()->isEmpty())
-                            <div class="py-6 text-center text-sm text-gray-500">暂无卷子数据</div>
-                        @endif
                     </div>
                 </x-filament::section>
 
-                <x-filament::section heading="卷子原始 Markdown">
-                    <div class="prose prose-sm max-w-none bg-gray-50 p-4 rounded-lg min-h-[240px]">
+                {{-- 内容预览 --}}
+                <div class="bg-white rounded-xl shadow-md border border-slate-200 overflow-hidden">
+                    <div class="px-4 py-3 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
+                        <div class="flex items-center gap-2">
+                            <span class="text-xs font-black text-slate-500 uppercase tracking-widest">卷子内容预览</span>
+                            @if($this->selectedPaper())
+                                <x-filament::badge size="xs" color="gray" class="bg-slate-200/50 text-slate-600 font-medium border-slate-300">{{ $this->selectedPaper()?->title }}</x-filament::badge>
+                            @endif
+                        </div>
+                    </div>
+                    <div class="p-8 custom-scroll overflow-y-auto bg-slate-50/30" style="height: 600px;">
                         @if($this->selectedPaper())
-                            {!! \App\Services\MathFormulaProcessor::processFormulas($this->selectedPaper()?->raw_markdown ?? '') !!}
+                            <div class="prose prose-slate max-w-none transition-all duration-300">
+                                <div class="markdown-body !bg-transparent p-0">
+                                    {!! \App\Services\MathFormulaProcessor::processFormulas($this->selectedPaper()?->raw_markdown ?? '') !!}
+                                </div>
+                            </div>
                         @else
-                            <div class="text-sm text-gray-400">暂无选中卷子</div>
+                            <div class="h-full flex flex-col items-center justify-center text-slate-400 gap-4 py-20">
+                                <x-heroicon-o-document-magnifying-glass class="w-16 h-16 opacity-30" />
+                                <div class="text-sm font-medium">点击左侧列表查看原始文本内容</div>
+                            </div>
                         @endif
                     </div>
-                </x-filament::section>
+                </div>
             </div>
 
-            <div class="col-span-4 space-y-4">
-                <x-filament::section heading="卷子归属信息">
-                    @php
-                        $textbookSuggestions = $this->textbookSuggestions();
-                        $catalogSuggestions = $this->catalogSuggestions();
-                        $coverageSummary = $this->catalogCoverageSummary();
-                        $missingCatalogNodes = $this->missingCatalogNodes();
-                    @endphp
-                    <div class="space-y-3">
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.title" placeholder="卷子标题" />
-                        </x-filament::input.wrapper>
+            {{-- 右侧:配置与控制 --}}
+            <div class="col-span-12 lg:col-span-4 space-y-6">
+                {{-- 1. 归属定义卡片 --}}
+                <x-filament::section>
+                    <x-slot name="heading">
+                        <div class="flex items-center gap-2">
+                            <x-heroicon-o-identification class="w-4 h-4 text-primary-500" />
+                            归属定义
+                        </div>
+                    </x-slot>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.bundle_key" placeholder="套卷标识(如:九年级上册·同步卷)" />
-                        </x-filament::input.wrapper>
+                    <div class="space-y-4">
+                        <div class="space-y-1">
+                            <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest">基础标题</label>
+                            <x-filament::input.wrapper>
+                                <x-filament::input wire:model="form.title" placeholder="例如:期中模拟卷 A" class="font-bold text-slate-800" />
+                            </x-filament::input.wrapper>
+                        </div>
 
-                        @if(!empty($textbookSuggestions))
-                            <div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs">
-                                <div class="font-semibold text-slate-600 mb-2">教材推荐</div>
-                                <div class="flex flex-col gap-2">
-                                    @foreach($textbookSuggestions as $suggest)
-                                        <button type="button" wire:click="applyTextbookSuggestion({{ $suggest['id'] }})" class="text-left rounded-md border border-slate-200 bg-white px-3 py-2 hover:border-primary-400">
-                                            <div class="text-slate-800 font-medium">{{ $suggest['title'] }}</div>
-                                            <div class="text-slate-500 mt-1">系列:{{ $suggest['series'] }} · 年级 {{ $suggest['grade'] ?? '-' }} · 学期 {{ $suggest['semester'] ?? '-' }}</div>
-                                        </button>
-                                    @endforeach
-                                </div>
+                        <div class="p-3 bg-slate-50/50 rounded-xl border border-slate-100 space-y-3">
+                            <div class="space-y-1">
+                                <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest">教材系列</label>
+                                <x-filament::input.wrapper>
+                                    <x-filament::input.select wire:model.live="form.textbook_series_id" class="font-medium">
+                                        <option value="">[ 未选中系列 ]</option>
+                                        @foreach($this->seriesOptions() as $id => $label)
+                                            <option value="{{ $id }}">{{ $label }}</option>
+                                        @endforeach
+                                    </x-filament::input.select>
+                                </x-filament::input.wrapper>
                             </div>
-                        @endif
-
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="form.grade">
-                                <option value="">年级</option>
-                                @foreach($this->gradeOptions() as $value => $label)
-                                    <option value="{{ $value }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="form.term">
-                                <option value="">学期</option>
-                                @foreach($this->termOptions() as $value => $label)
-                                    <option value="{{ $value }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.chapter" placeholder="章节" />
-                        </x-filament::input.wrapper>
-
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="form.source_type">
-                                <option value="">卷子类型</option>
-                                @foreach($this->sourceTypeOptions() as $value => $label)
-                                    <option value="{{ $value }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.source_year" placeholder="来源年份" />
-                        </x-filament::input.wrapper>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="form.textbook_id">
-                                <option value="">匹配教材</option>
-                                @foreach($this->textbookOptions() as $id => $title)
-                                    <option value="{{ $id }}">{{ $title }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.textbook_series" placeholder="教材系列" />
-                        </x-filament::input.wrapper>
+                            <div class="grid grid-cols-2 gap-3">
+                                <div class="space-y-1">
+                                    <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest">年级</label>
+                                    <x-filament::input.wrapper>
+                                        <x-filament::input.select wire:model.live="form.grade">
+                                            <option value="">选择年级</option>
+                                            @foreach($this->gradeOptions() as $value => $label)
+                                                <option value="{{ $value }}">{{ $label }}</option>
+                                            @endforeach
+                                        </x-filament::input.select>
+                                    </x-filament::input.wrapper>
+                                </div>
+                                <div class="space-y-1">
+                                    <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest">学期</label>
+                                    <x-filament::input.wrapper>
+                                        <x-filament::input.select wire:model.live="form.term">
+                                            <option value="">选择学期</option>
+                                            @foreach($this->termOptions() as $value => $label)
+                                                <option value="{{ $value }}">{{ $label }}</option>
+                                            @endforeach
+                                        </x-filament::input.select>
+                                    </x-filament::input.wrapper>
+                                </div>
+                            </div>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="form.catalog_node_id">
-                                <option value="">关联目录</option>
-                                @foreach($this->catalogOptions() as $id => $label)
-                                    <option value="{{ $id }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
+                            <div class="space-y-1 pt-1">
+                                <div class="flex items-center justify-between">
+                                    <label class="text-[10px] font-black text-primary-500 uppercase tracking-widest">目标教材关联</label>
+                                    <div wire:loading wire:target="form.grade, form.term, form.textbook_series_id, form.textbook_id" class="text-[9px] text-primary-500 animate-pulse font-bold">同步中...</div>
+                                </div>
+                                <x-filament::input.wrapper wire:key="textbook-select-parent-{{ $this->form['textbook_series_id'] ?? 'none' }}">
+                                    <x-filament::input.select wire:model.live="form.textbook_id" @class([
+                                        'font-black',
+                                        'text-primary-600' => !empty($this->form['textbook_id']),
+                                        'text-rose-500 underline decoration-dotted' => empty($this->form['textbook_id']),
+                                    ])>
+                                        <option value="">[ 自动寻找最匹配教材 ]</option>
+                                        @foreach($this->textbookOptions() as $id => $title)
+                                            <option value="{{ $id }}">{{ $title }}</option>
+                                        @endforeach
+                                    </x-filament::input.select>
+                                </x-filament::input.wrapper>
+                            </div>
+                        </div>
+                    </div>
+                </x-filament::section>
 
-                        @if(!empty($catalogSuggestions))
-                            <div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs">
-                                <div class="font-semibold text-slate-600 mb-2">目录推荐</div>
-                                <div class="flex flex-col gap-2">
-                                    @foreach($catalogSuggestions as $suggest)
-                                        <button type="button" wire:click="applyCatalogSuggestion({{ $suggest['id'] }})" class="text-left rounded-md border border-slate-200 bg-white px-3 py-2 hover:border-primary-400">
-                                            <div class="text-slate-800 font-medium">{{ $suggest['title'] }}</div>
-                                        </button>
-                                    @endforeach
+                {{-- 2. 章节目录卡片 (关键:修复点击保存不生效的逻辑引导) --}}
+                <x-filament::section collapsible>
+                    <x-slot name="heading">
+                        <div class="flex items-center gap-2">
+                            <x-heroicon-o-list-bullet class="w-4 h-4 text-warning-500" />
+                            关联目录章节
+                        </div>
+                    </x-slot>
+                    
+                    <div class="space-y-4">
+                        <div wire:key="catalog-list-{{ $this->form['textbook_id'] ?? 'none' }}" class="overflow-y-auto border border-slate-200 rounded-xl p-2 bg-slate-50/30 divide-y divide-slate-100 custom-scroll" style="max-height: 400px;">
+                            @forelse($this->catalogOptions() as $id => $label)
+                                <label class="flex items-center gap-3 py-2 px-3 hover:bg-white transition-all cursor-pointer group rounded-lg">
+                                    <input type="checkbox" wire:model.defer="form.catalog_node_ids" value="{{ $id }}" class="rounded-sm border-slate-300 text-primary-600 focus:ring-primary-500">
+                                    <span class="text-[11px] font-bold text-slate-700 group-hover:text-primary-700">{{ $label }}</span>
+                                </label>
+                            @empty
+                                <div class="py-12 text-center text-slate-400">
+                                    <x-heroicon-m-document-magnifying-glass class="w-10 h-10 mx-auto mb-3 opacity-20" />
+                                    <p class="text-[11px] italic font-medium">请先指定教材以加载章节路径</p>
                                 </div>
+                            @endforelse
+                        </div>
+                        
+                        @if(!empty($this->form['catalog_node_ids']))
+                            <div class="text-[10px] text-slate-400 italic flex items-center gap-1 px-1">
+                                <x-heroicon-m-check-circle class="w-3 h-3 text-emerald-500" />
+                                已选择 {{ count($this->form['catalog_node_ids']) }} 个节点
                             </div>
                         @endif
+                    </div>
+                </x-filament::section>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.expected_count" placeholder="预期题量(如 24)" />
-                        </x-filament::input.wrapper>
+                {{-- 3. 补充元数据 (默认折叠,减少干扰) --}}
+                <x-filament::section collapsible collapsed>
+                    <x-slot name="heading">
+                        <div class="flex items-center gap-2">
+                            <x-heroicon-o-plus-circle class="w-4 h-4 text-slate-400" />
+                            补充元数据
+                        </div>
+                    </x-slot>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.source_name" placeholder="来源名称" />
-                        </x-filament::input.wrapper>
+                    <div class="space-y-3 mt-1">
+                        <div class="grid grid-cols-2 gap-3">
+                            <div class="space-y-1">
+                                <label class="text-[10px] font-bold text-slate-500">预期题数</label>
+                                <x-filament::input.wrapper>
+                                    <x-filament::input wire:model="form.expected_count" placeholder="例如: 25" />
+                                </x-filament::input.wrapper>
+                            </div>
+                            <div class="space-y-1">
+                                <label class="text-[10px] font-bold text-slate-500">发布年份</label>
+                                <x-filament::input.wrapper>
+                                    <x-filament::input wire:model="form.source_year" placeholder="例如: 2024" />
+                                </x-filament::input.wrapper>
+                            </div>
+                        </div>
+                        
+                        <div class="space-y-1">
+                            <label class="text-[10px] font-bold text-slate-500">来源名称</label>
+                            <x-filament::input.wrapper>
+                                <x-filament::input wire:model="form.source_name" placeholder="来源名称 (例: 课课练)" />
+                            </x-filament::input.wrapper>
+                        </div>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.source_page" placeholder="页码范围" />
-                        </x-filament::input.wrapper>
+                        <div class="grid grid-cols-2 gap-3">
+                            <div class="space-y-1">
+                                <label class="text-[10px] font-bold text-slate-500">页码范围</label>
+                                <x-filament::input.wrapper>
+                                    <x-filament::input wire:model="form.source_page" placeholder="例如: 1-4" />
+                                </x-filament::input.wrapper>
+                            </div>
+                            <div class="space-y-1">
+                                <label class="text-[10px] font-bold text-slate-500">通用标签</label>
+                                <x-filament::input.wrapper>
+                                    <x-filament::input wire:model="form.tags" placeholder="标签(逗号隔开)" />
+                                </x-filament::input.wrapper>
+                            </div>
+                        </div>
 
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="form.tags" placeholder="标签(逗号分隔)" />
-                        </x-filament::input.wrapper>
+                        <div class="bg-amber-50 rounded-xl p-3 border border-amber-100 shadow-sm">
+                            <div class="text-[10px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
+                                <x-heroicon-m-sparkles class="w-3.5 h-3.5" />
+                                智能预检测系统
+                            </div>
+                            <div class="text-[11px] text-amber-800 leading-relaxed font-medium space-y-1">
+                                <div>推断类型:<span class="font-bold underline decoration-amber-300">{{ $this->form['source_type'] ?? '未识别类型' }}</span></div>
+                                <div>推断章节:<span class="font-bold underline decoration-amber-300">{{ $this->form['chapter'] ?? '未识别章节' }}</span></div>
+                            </div>
+                        </div>
                     </div>
                 </x-filament::section>
 
-                <x-filament::section heading="批量覆盖">
-                    <div class="text-xs text-gray-500 mb-2">对勾选卷子批量应用非空字段</div>
+                {{-- 批量操作区 (极致紧凑设计) --}}
+                <div class="bg-slate-900 rounded-2xl p-5 space-y-4 shadow-xl border-t-2 border-primary-500">
+                    <div class="flex items-center justify-between gap-2 mb-2">
+                        <div class="flex items-center gap-2">
+                            <x-heroicon-o-bolt class="w-4 h-4 text-warning-400" />
+                            <span class="text-xs font-black text-white uppercase tracking-widest">批量高效工具柜</span>
+                        </div>
+                        <x-filament::badge color="warning" size="xs">{{ count($this->selectedIds) }} 卷选中</x-filament::badge>
+                    </div>
+                    
                     <div class="space-y-2">
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="batch.bundle_key" placeholder="套卷标识" />
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="batch.grade">
-                                <option value="">年级</option>
-                                @foreach($this->gradeOptions() as $value => $label)
-                                    <option value="{{ $value }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="batch.term">
-                                <option value="">学期</option>
-                                @foreach($this->termOptions() as $value => $label)
-                                    <option value="{{ $value }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="batch.source_type">
-                                <option value="">卷子类型</option>
-                                @foreach($this->sourceTypeOptions() as $value => $label)
-                                    <option value="{{ $value }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="batch.textbook_id">
-                                <option value="">匹配教材</option>
-                                @foreach($this->textbookOptions() as $id => $title)
-                                    <option value="{{ $id }}">{{ $title }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input.select wire:model="batch.catalog_node_id">
-                                <option value="">关联目录</option>
-                                @foreach($this->catalogOptions() as $id => $label)
-                                    <option value="{{ $id }}">{{ $label }}</option>
-                                @endforeach
-                            </x-filament::input.select>
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="batch.expected_count" placeholder="预期题量" />
-                        </x-filament::input.wrapper>
-                        <x-filament::input.wrapper>
-                            <x-filament::input wire:model="batch.tags" placeholder="标签(逗号分隔)" />
-                        </x-filament::input.wrapper>
-                        <x-filament::button color="gray" wire:click="seedBatchFromCurrent">以当前卷为默认</x-filament::button>
-                        <x-filament::button color="warning" x-on:click.prevent="if(confirm('确认对勾选卷子批量覆盖?')) { $wire.applyBatch() }">
-                            批量覆盖
-                        </x-filament::button>
+                        <x-filament::input.select wire:model.live="batch.textbook_series_id" size="sm" class="bg-slate-800 border-none text-white text-[11px]">
+                            <option value="">批量系列...</option>
+                            @foreach($this->seriesOptions() as $id => $name)
+                                <option value="{{ $id }}">{{ $name }}</option>
+                            @endforeach
+                        </x-filament::input.select>
+
+                        <x-filament::input.select wire:model="batch.textbook_id" size="sm" class="bg-slate-800 border-none text-white text-[11px]" wire:key="batch-textbook-{{ $this->batch['textbook_series_id'] ?? 'none' }}">
+                            <option value="">批量教材...</option>
+                            @foreach($this->textbookOptions($this->batch['textbook_series_id'] ?? null) as $id => $label)
+                                <option value="{{ $id }}">{{ $label }}</option>
+                            @endforeach
+                        </x-filament::input.select>
+                    </div>
+
+                    <div class="grid grid-cols-2 gap-2">
+                        <x-filament::input.select wire:model="batch.grade" size="sm" class="bg-slate-800 border-none text-white text-[11px]">
+                            <option value="">年级...</option>
+                            @foreach($this->gradeOptions() as $value => $label)
+                                <option value="{{ $value }}">{{ $label }}</option>
+                            @endforeach
+                        </x-filament::input.select>
+                        <x-filament::input.select wire:model="batch.term" size="sm" class="bg-slate-800 border-none text-white text-[11px]">
+                            <option value="">学期...</option>
+                            @foreach($this->termOptions() as $value => $label)
+                                <option value="{{ $value }}">{{ $label }}</option>
+                            @endforeach
+                        </x-filament::input.select>
                     </div>
-                </x-filament::section>
 
-                @if(!empty($coverageSummary))
-                    <x-filament::section heading="目录覆盖提示">
-                        <div class="text-xs text-slate-500 mb-2">
-                            目录总数 {{ $coverageSummary['total'] }} · 已关联 {{ $coverageSummary['linked'] }} · 缺卷子目录 {{ $coverageSummary['missing'] }}
+                    <div class="space-y-1 pt-1">
+                        <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">关联目录批量</label>
+                        <div wire:key="batch-catalog-{{ $this->batch['textbook_id'] ?? 'none' }}" class="overflow-y-auto border border-slate-700/50 rounded-xl p-2 bg-slate-800 divide-y divide-slate-700/30 custom-scroll" style="height: 150px;">
+                            @forelse($this->catalogOptions($this->batch['textbook_id'] ?? null) as $id => $label)
+                                <label class="flex items-center gap-2 py-1.5 px-2 hover:bg-slate-700 transition-colors cursor-pointer rounded-lg group">
+                                    <input type="checkbox" wire:model.defer="batch.catalog_node_ids" value="{{ $id }}" class="rounded-sm border-slate-600 bg-slate-700 text-primary-500 focus:ring-primary-400">
+                                    <span class="text-[10px] text-slate-300 group-hover:text-white font-medium">{{ $label }}</span>
+                                </label>
+                            @empty
+                                <div class="text-[10px] text-slate-500 py-10 italic text-center">选择教材以加载目录</div>
+                            @endforelse
                         </div>
-                        @if(!empty($missingCatalogNodes))
-                            <div class="space-y-2">
-                                @foreach($missingCatalogNodes as $node)
-                                    <button type="button" wire:click="applyCatalogSuggestion({{ $node['id'] }})" class="text-left w-full rounded-lg border border-slate-200 bg-white px-3 py-2 hover:border-primary-400">
-                                        <div class="text-sm text-slate-800">{{ $node['title'] }}</div>
-                                        <div class="text-xs text-slate-500">点击绑定到当前卷子</div>
-                                    </button>
-                                @endforeach
-                            </div>
-                        @else
-                            <div class="text-xs text-slate-500">暂无缺口目录</div>
-                        @endif
-                    </x-filament::section>
-                @endif
+                    </div>
+
+                    <div class="grid grid-cols-2 gap-3 pt-2">
+                        <x-filament::button 
+                            color="gray" 
+                            size="sm" 
+                            icon="heroicon-m-arrow-left-on-rectangle" 
+                            wire:click="seedBatchFromCurrent"
+                            class="bg-slate-800 border-none hover:bg-slate-700 text-slate-300"
+                        >
+                            复制配置
+                        </x-filament::button>
+
+                        <x-filament::button 
+                            color="warning" 
+                            icon="heroicon-o-fire" 
+                            size="sm"
+                            class="shadow-orange-500/20 shadow-lg"
+                            x-on:click.prevent="if(confirm('确认将批量设置覆盖到勾选的卷子吗?')) { $wire.applyBatch() }"
+                        >
+                            执行覆盖
+                        </x-filament::button>
+                    </div>
+                </div>
             </div>
         </div>
         @endif

+ 3 - 0
resources/views/filament/pages/prompt-management.blade.php

@@ -20,6 +20,9 @@
                         class="w-full rounded-lg border-gray-300 text-sm"
                     >
                         <option value="">全部类型</option>
+                        <option value="question_generation">题目生成(系统)</option>
+                        <option value="question_enrich">题干补全(系统)</option>
+                        <option value="question_solution_regen">解题重写(系统)</option>
                         <option value="题目生成">题目生成</option>
                         <option value="掌握度评估">掌握度评估</option>
                         <option value="技能熟练度">技能熟练度</option>

+ 3 - 5
resources/views/filament/pages/question-candidate-workbench.blade.php

@@ -40,7 +40,7 @@
             <x-filament::button color="gray" wire:click="previousCandidate">上一题 ←</x-filament::button>
             <x-filament::button color="gray" wire:click="nextCandidate">下一题 →</x-filament::button>
             <x-filament::button color="primary" wire:click="saveCandidate">保存当前</x-filament::button>
-            <x-filament::button color="gray" wire:click="$set('dense', ! $wire.dense)">
+            <x-filament::button color="gray" wire:click="$toggle('dense')">
                 密度切换
             </x-filament::button>
             <x-filament::button color="gray" wire:click="$set('viewMode', 'list')">表格视图</x-filament::button>
@@ -139,8 +139,7 @@
                                 <option value="">题型</option>
                                 <option value="choice">选择题</option>
                                 <option value="fill">填空题</option>
-                                <option value="short">简答题</option>
-                                <option value="calc">计算题</option>
+                                <option value="answer">解答题</option>
                             </x-filament::input.select>
                         </x-filament::input.wrapper>
 
@@ -254,8 +253,7 @@
                                 <option value="">题型</option>
                                 <option value="choice">选择题</option>
                                 <option value="fill">填空题</option>
-                                <option value="short">简答题</option>
-                                <option value="calc">计算题</option>
+                                <option value="answer">解答题</option>
                             </x-filament::input.select>
                         </x-filament::input.wrapper>
                         <x-filament::input.wrapper>

+ 79 - 8
resources/views/filament/pages/question-detail.blade.php

@@ -71,6 +71,28 @@
                                     </div>
                                 @endif
 
+                                @php
+                                    $rawImages = $this->questionData['images']
+                                        ?? ($this->questionData['meta']['images'] ?? ($this->questionData['meta']['generated_question']['images'] ?? []));
+                                    if (is_string($rawImages)) {
+                                        $decodedImages = json_decode($rawImages, true);
+                                        $rawImages = is_array($decodedImages) ? $decodedImages : [];
+                                    }
+                                    $images = is_array($rawImages) ? array_values(array_filter($rawImages)) : [];
+                                @endphp
+                                @if (!empty($images))
+                                    <div class="mt-4">
+                                        <h4 class="text-sm font-medium text-gray-700 mb-2">题图</h4>
+                                        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+                                            @foreach($images as $img)
+                                                <div class="bg-white border rounded-lg p-2">
+                                                    <img src="{{ $img }}" alt="题目图片" class="w-full h-auto rounded">
+                                                </div>
+                                            @endforeach
+                                        </div>
+                                    </div>
+                                @endif
+
                                 {{-- 标签 --}}
                                 <div class="flex flex-wrap gap-2 mb-4">
                                     @php
@@ -126,11 +148,27 @@
                                             @endphp
                                             @foreach($options as $idx => $option)
                                                 @php
-                                                    $label = $letters[$idx] ?? chr(65 + ($idx % 26));
+                                                    $optionText = $option;
+                                                    $label = null;
+                                                    if (is_array($option)) {
+                                                        $label = $option['label'] ?? $option['key'] ?? null;
+                                                        $optionText = $option['text'] ?? $option['value'] ?? $optionText;
+                                                    }
+                                                    if ($label === null) {
+                                                        if (is_numeric($idx)) {
+                                                            $index = (int) $idx;
+                                                            $label = $letters[$index] ?? chr(65 + ($index % 26));
+                                                        } else {
+                                                            $label = strtoupper((string) $idx);
+                                                        }
+                                                    }
+                                                    if (is_array($optionText)) {
+                                                        $optionText = json_encode($optionText, JSON_UNESCAPED_UNICODE);
+                                                    }
                                                 @endphp
                                                 <div class="flex items-start space-x-2">
                                                     <span class="font-semibold text-gray-700">{{ $label }}.</span>
-                                                    <div class="text-gray-900">{!! $option !!}</div>
+                                                    <div class="text-gray-900">{!! $optionText !!}</div>
                                                 </div>
                                             @endforeach
                                         </div>
@@ -165,12 +203,45 @@
                                     </div>
                                 @else
                                     {{-- 正确答案 --}}
-                                    @if (!empty($this->questionData['answer']))
-                                        <div class="p-4 bg-green-50 rounded-lg mb-4">
-                                            <h4 class="text-sm font-medium text-green-700 mb-2">正确答案</h4>
-                                            <p class="text-gray-900 text-lg font-mono leading-relaxed">{!! \App\Services\MathFormulaProcessor::processFormulas($this->questionData['answer']) !!}</p>
-                                        </div>
-                                    @endif
+                                @if (!empty($this->questionData['answer']))
+                                    <div class="p-4 bg-green-50 rounded-lg mb-4">
+                                        <h4 class="text-sm font-medium text-green-700 mb-2">正确答案</h4>
+                                        <p class="text-gray-900 text-lg font-mono leading-relaxed">{!! \App\Services\MathFormulaProcessor::processFormulas($this->questionData['answer']) !!}</p>
+                                    </div>
+                                @endif
+
+                                <div class="p-4 bg-white rounded-lg border border-gray-200 mb-4">
+                                    <h4 class="text-sm font-medium text-gray-700 mb-2">答案校正与 AI 解题</h4>
+                                    <textarea
+                                        wire:model.defer="answerOverride"
+                                        class="w-full rounded-lg border-gray-300 text-sm"
+                                        rows="3"
+                                        placeholder="手动修正答案,保存后再重新生成解题思路"
+                                    ></textarea>
+                                    <div class="mt-3 flex flex-wrap gap-2">
+                                        <button
+                                            wire:click="saveAnswerOverride"
+                                            class="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-900 text-white hover:bg-gray-800"
+                                        >
+                                            保存答案
+                                        </button>
+                                        <button
+                                            wire:click="regenerateSolution"
+                                            wire:loading.attr="disabled"
+                                            wire:target="regenerateSolution"
+                                            class="px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-500"
+                                        >
+                                            <span wire:loading.remove wire:target="regenerateSolution">AI 重新生成解题思路</span>
+                                            <span wire:loading wire:target="regenerateSolution" class="inline-flex items-center gap-2">
+                                                <svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
+                                                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                                                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
+                                                </svg>
+                                                正在生成...
+                                            </span>
+                                        </button>
+                                    </div>
+                                </div>
                                 @endif
 
                                 {{-- 解题思路 --}}

+ 21 - 7
resources/views/filament/pages/question-management-simple.blade.php

@@ -78,14 +78,14 @@
     </div>
 
     <!-- 题型汇总统计 -->
-    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
+    <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
         <div class="bg-white p-4 rounded-lg border">
             <div class="text-sm text-gray-500">选择题</div>
             <div class="text-2xl font-bold text-blue-600">
                 @php
                     $choiceCount = 0;
                     foreach ($displayStats['by_type'] ?? [] as $type => $count) {
-                        if ($type === '选择题') {
+                        if ($type === '选择题' || $type === '多选题') {
                             $choiceCount += $count;
                         }
                     }
@@ -108,16 +108,30 @@
             </div>
         </div>
         <div class="bg-white p-4 rounded-lg border">
-            <div class="text-sm text-gray-500">简单题</div>
+            <div class="text-sm text-gray-500">解答题</div>
             <div class="text-2xl font-bold text-orange-600">
                 @php
-                    $simpleCount = 0;
+                    $calcCount = 0;
+                    foreach ($displayStats['by_type'] ?? [] as $type => $count) {
+                        if (in_array($type, ['解答题', '证明题'])) {
+                            $calcCount += $count;
+                        }
+                    }
+                    echo $calcCount;
+                @endphp
+            </div>
+        </div>
+        <div class="bg-white p-4 rounded-lg border">
+            <div class="text-sm text-gray-500">其他</div>
+            <div class="text-2xl font-bold text-gray-600">
+                @php
+                    $otherCount = 0;
                     foreach ($displayStats['by_type'] ?? [] as $type => $count) {
-                        if (in_array($type, ['解答题', '其他'])) {
-                            $simpleCount += $count;
+                        if ($type === '其他') {
+                            $otherCount += $count;
                         }
                     }
-                    echo $simpleCount;
+                    echo $otherCount;
                 @endphp
             </div>
         </div>

+ 1 - 1
resources/views/filament/resources/markdown-import-resource/pages/list-markdown-imports.blade.php

@@ -1,4 +1,4 @@
-<div class="ui-page">
+<div class="ui-page" wire:poll.5s>
     <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
         @include('filament.partials.page-header', [
             'kicker' => 'Markdown 导入',

+ 100 - 0
routes/api.php

@@ -949,3 +949,103 @@ Route::get('/student-answers/history/{studentId}', [StudentAnswerAnalysisControl
         'auth:api',
     ])
     ->name('api.student-answers.history');
+
+/*
+|--------------------------------------------------------------------------
+| 考试答题分析 API 路由(步骤级分析)
+|--------------------------------------------------------------------------
+*/
+
+use App\Http\Controllers\Api\ExamAnswerAnalysisController;
+
+// 分析考试答题数据
+Route::post('/exam-answer-analysis', [ExamAnswerAnalysisController::class, 'analyze'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.exam-answer-analysis.analyze');
+
+// 获取分析结果
+Route::get('/exam-answer-analysis/{student_id}/{exam_id}', [ExamAnswerAnalysisController::class, 'getAnalysisResult'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->where('student_id', '.*')
+    ->where('exam_id', '.*')
+    ->name('api.exam-answer-analysis.result');
+
+// 获取学生历史分析记录
+Route::get('/exam-answer-analysis/history/{student_id}', [ExamAnswerAnalysisController::class, 'getHistory'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->where('student_id', '.*')
+    ->name('api.exam-answer-analysis.history');
+
+// 获取知识点掌握度趋势
+Route::get('/exam-answer-analysis/mastery-trend/{student_id}', [ExamAnswerAnalysisController::class, 'getMasteryTrend'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->where('student_id', '.*')
+    ->name('api.exam-answer-analysis.mastery-trend');
+
+// 获取智能出卷推荐
+Route::get('/exam-answer-analysis/smart-quiz/{student_id}', [ExamAnswerAnalysisController::class, 'getSmartQuizRecommendation'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->where('student_id', '.*')
+    ->name('api.exam-answer-analysis.smart-quiz');
+
+// 导出分析报告
+Route::get('/exam-answer-analysis/export/{student_id}/{exam_id}', [ExamAnswerAnalysisController::class, 'export'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->where('student_id', '.*')
+    ->where('exam_id', '.*')
+    ->name('api.exam-answer-analysis.export');
+
+// 批量分析多个学生的考试数据
+Route::post('/exam-answer-analysis/batch', [ExamAnswerAnalysisController::class, 'batchAnalyze'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.exam-answer-analysis.batch');
+
+Route::get('/tasks/status/{taskId}', function (string $taskId) {
+    $task = app(\App\Services\TaskManager::class)->getTaskStatus($taskId);
+    if (!$task) {
+        return response()->json([
+            'success' => false,
+            'message' => '任务不存在',
+        ], 404);
+    }
+
+    return response()->json([
+        'success' => true,
+        'data' => $task,
+    ]);
+})->name('api.tasks.status');