Просмотр исходного кода

知识点掌握度快照等功能

yemeishu 1 неделя назад
Родитель
Сommit
ee45e11b56

+ 55 - 23
app/Filament/Resources/MarkdownImportResource.php

@@ -5,10 +5,10 @@ namespace App\Filament\Resources;
 use App\Filament\Resources\MarkdownImportResource\Pages;
 use App\Models\MarkdownImport;
 use BackedEnum;
-use Filament\Actions\Action;
 use Filament\Actions\BulkActionGroup;
 use Filament\Actions\DeleteBulkAction;
 use Filament\Actions\EditAction;
+use Filament\Actions\Action;
 use Filament\Facades\Filament;
 use Filament\Notifications\Notification;
 use Filament\Forms\Components\FileUpload;
@@ -27,6 +27,7 @@ use UnitEnum;
 use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
 use App\Support\TextEncoding;
 use App\Rules\MarkdownFileExtension;
+use Filament\Tables\Columns\TextColumn;
 
 class MarkdownImportResource extends Resource
 {
@@ -172,21 +173,16 @@ class MarkdownImportResource extends Resource
     {
         return $table
             ->columns([
-                Tables\Columns\TextColumn::make('file_name')
+                TextColumn::make('file_name')
                     ->label('文件名')
                     ->searchable()
                     ->sortable(),
 
-                Tables\Columns\TextColumn::make('source_type')
-                    ->label('来源类型')
-                    ->badge()
-                    ->color('gray'),
-
-                Tables\Columns\TextColumn::make('source_name')
-                    ->label('来源名称')
-                    ->searchable(),
+                TextColumn::make('source_name')
+                    ->label('来源')
+                    ->toggleable(isToggledHiddenByDefault: true),
 
-                Tables\Columns\TextColumn::make('status')
+                TextColumn::make('status')
                     ->label('状态')
                     ->badge()
                     ->color(fn (string $state): string => match ($state) {
@@ -214,38 +210,47 @@ class MarkdownImportResource extends Resource
                         };
                     }),
 
-                Tables\Columns\TextColumn::make('progress_message')
+                TextColumn::make('progress_message')
                     ->label('当前步骤')
                     ->getStateUsing(fn (?Model $record) => $record?->progress_message ?: '—')
                     ->wrap()
                     ->limit(60),
 
-                Tables\Columns\TextColumn::make('progress_updated_at')
-                    ->label('进度更新时间')
-                    ->dateTime('m-d H:i:s')
-                    ->sortable()
-                    ->toggleable(isToggledHiddenByDefault: true),
+                TextColumn::make('progress_label')
+                    ->label('进度')
+                    ->getStateUsing(fn (?Model $record) => $record?->progress_label ?: '—')
+                    ->color('gray'),
 
-                Tables\Columns\TextColumn::make('parsed_count')
+                TextColumn::make('parsed_count')
                     ->label('候选题数')
                     ->getStateUsing(fn (?Model $record) => $record?->parsed_count ?? 0)
                     ->sortable(),
 
-                Tables\Columns\TextColumn::make('accepted_count')
+                TextColumn::make('accepted_count')
                     ->label('已接受')
                     ->getStateUsing(fn (?Model $record) => $record?->accepted_count ?? 0)
                     ->sortable(),
 
-                Tables\Columns\TextColumn::make('created_at')
+                TextColumn::make('created_at')
                     ->label('导入时间')
                     ->dateTime()
                     ->sortable(),
 
-                Tables\Columns\TextColumn::make('error_message')
-                    ->label('错误信息')
+                TextColumn::make('processing_started_at')
+                    ->label('开始')
+                    ->dateTime('m-d H:i')
+                    ->toggleable(isToggledHiddenByDefault: true),
+
+                TextColumn::make('processing_finished_at')
+                    ->label('结束')
+                    ->dateTime('m-d H:i')
+                    ->toggleable(isToggledHiddenByDefault: true),
+
+                TextColumn::make('error_message')
+                    ->label('错误')
                     ->visible(fn (?Model $record): bool => $record?->status === 'failed')
                     ->wrap()
-                    ->limit(50),
+                    ->limit(80),
             ])
             ->filters([
                 Tables\Filters\SelectFilter::make('status')
@@ -271,6 +276,33 @@ class MarkdownImportResource extends Resource
                 EditAction::make()
                     ->label('编辑'),
 
+                Action::make('run_pipeline')
+                    ->label('触发全流程')
+                    ->icon('heroicon-o-play-circle')
+                    ->color('success')
+                    ->requiresConfirmation()
+                    ->modalHeading('触发 Markdown 拆分 + AI 结构化')
+                    ->modalDescription('立即提交队列,按 source_file → source_paper → paper_part → candidate → AI 结构化 执行。')
+                    ->action(function (?Model $record) {
+                        if (!$record) {
+                            return;
+                        }
+                        dispatch(new \App\Jobs\ProcessMarkdownSplit($record->id));
+                        $record->update([
+                            'status' => MarkdownImport::STATUS_PROCESSING,
+                            'progress_stage' => MarkdownImport::STAGE_QUEUED,
+                            'progress_message' => '已进入队列…',
+                            'processing_started_at' => now(),
+                            'processing_finished_at' => null,
+                            'error_message' => null,
+                        ]);
+
+                        Notification::make()
+                            ->title('已提交解析队列')
+                            ->success()
+                            ->send();
+                    }),
+
                 Action::make('parse')
                     ->label('解析 Markdown')
                     ->icon('heroicon-o-cog-6-tooth')

+ 36 - 8
app/Filament/Resources/PreQuestionCandidateResource.php

@@ -14,6 +14,7 @@ use Filament\Forms\Components\Toggle;
 use Filament\Resources\Resource;
 use Filament\Schemas\Components\Section;
 use Filament\Tables;
+use Filament\Tables\Columns\TextColumn;
 use Filament\Tables\Filters\TernaryFilter;
 use Illuminate\Database\Eloquent\Model;
 use UnitEnum;
@@ -68,20 +69,37 @@ class PreQuestionCandidateResource extends Resource
     {
         return $table
             ->columns([
-                Tables\Columns\TextColumn::make('sequence')
+                TextColumn::make('sequence')
                     ->label('序')
                     ->sortable()
                     ->width('60px'),
 
-                Tables\Columns\TextColumn::make('index')
+                TextColumn::make('index')
                     ->label('题号')
                     ->sortable()
                     ->width('70px'),
 
-                Tables\Columns\ViewColumn::make('raw_markdown')
+                TextColumn::make('question_number')
+                    ->label('原题号')
+                    ->sortable()
+                    ->toggleable()
+                    ->width('80px'),
+
+                TextColumn::make('part.title')
+                    ->label('区块')
+                    ->toggleable()
+                    ->limit(16),
+
+                TextColumn::make('sourcePaper.title')
+                    ->label('卷子')
+                    ->toggleable()
+                    ->limit(16),
+
+                TextColumn::make('raw_markdown')
                     ->label('题目预览')
-                    ->view('filament.tables.columns.markdown-preview')
-                    ->columnSpanFull(),
+                    ->wrap()
+                    ->limit(140)
+                    ->toggleable(),
 
                 Tables\Columns\ImageColumn::make('first_image')
                     ->label('图片')
@@ -89,16 +107,26 @@ class PreQuestionCandidateResource extends Resource
                     ->width(60)
                     ->circular(),
 
-                Tables\Columns\TextColumn::make('ai_confidence')
+                TextColumn::make('ai_confidence')
                     ->label('AI 置信度')
                     ->badge()
                     ->color(fn (Model $record): string => $record->confidence_badge)
-                    ->formatStateUsing(fn (?float $state): string => $state ? number_format($state * 100, 1) . '%' : 'N/A'),
+                    ->formatStateUsing(function (?float $state, Model $record): string {
+                        $val = $state ?? $record->confidence;
+                        return $val !== null ? number_format((float)$val * 100, 1) . '%' : 'N/A';
+                    }),
+
+                TextColumn::make('structured_json')
+                    ->label('结构化')
+                    ->getStateUsing(fn (?Model $record) => $record?->structured_json ? '已生成' : '未生成')
+                    ->badge()
+                    ->color(fn (?Model $record) => $record?->structured_json ? 'success' : 'gray')
+                    ->toggleable(isToggledHiddenByDefault: true),
 
                 Tables\Columns\ToggleColumn::make('is_question_candidate')
                     ->label('是题目'),
 
-                Tables\Columns\TextColumn::make('status')
+                TextColumn::make('status')
                     ->label('状态')
                     ->badge()
                     ->color(fn (Model $record): string => $record->status_badge),

+ 46 - 1
app/Jobs/ProcessMarkdownCandidateBatch.php

@@ -57,6 +57,27 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
 
         foreach ($records as $record) {
             try {
+                $existingConfidence = $record->confidence ?? $record->ai_confidence;
+
+                // 置信度高(>=0.85)时跳过再次 AI 解析,直接计入进度
+                if ($existingConfidence !== null && (float) $existingConfidence >= 0.85) {
+                    $processed++;
+                    continue;
+                }
+
+                // 快速过滤卷子/区块标题,避免误判为题目再次走 AI
+                if (!$this->isLikelyQuestion((string) $record->raw_markdown)) {
+                    $record->update([
+                        'is_question_candidate' => false,
+                        'ai_confidence' => 0.0,
+                        'confidence' => 0.0,
+                        'is_valid_question' => false,
+                        'status' => 'rejected',
+                    ]);
+                    $processed++;
+                    continue;
+                }
+
                 // 已经处理过的不重复处理
                 if (in_array($record->status, ['pending', 'reviewed', 'accepted', 'rejected'], true) && $record->stem !== null) {
                     continue;
@@ -144,5 +165,29 @@ class ProcessMarkdownCandidateBatch implements ShouldQueue
             ]);
         }
     }
-}
 
+    /**
+     * 轻量启发式判断是否像一道题目,过滤卷子/部分标题和说明文字。
+     */
+    private function isLikelyQuestion(string $raw): bool
+    {
+        $text = trim(strip_tags($raw));
+        $length = mb_strlen($text);
+
+        // Markdown 标题或“卷/部分/说明”且文本很短,视为非题
+        if (preg_match('/^#+\\s+/m', $raw)) {
+            return false;
+        }
+
+        if (preg_match('/(第[一二三四五六七八九十IVX]+[卷部分]|题型|说明|试卷)/u', $text) && $length <= 80) {
+            return false;
+        }
+
+        // 过短且无问句/命令词/选项特征
+        if ($length < 25 && !preg_match('/[\\??求解求证计算]|[A-D]\\.|(本小题满分/u', $text)) {
+            return false;
+        }
+
+        return true;
+    }
+}

+ 47 - 70
app/Jobs/ProcessMarkdownSplit.php

@@ -4,7 +4,10 @@ namespace App\Jobs;
 
 use App\Models\MarkdownImport;
 use App\Models\PreQuestionCandidate;
-use App\Services\AsyncMarkdownSplitter;
+use App\Services\SourceFileParserService;
+use App\Services\SourcePaperExtractorService;
+use App\Services\PaperPartExtractorService;
+use App\Services\QuestionExtractorService;
 use App\Jobs\ProcessMarkdownCandidateBatch;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -33,7 +36,7 @@ class ProcessMarkdownSplit implements ShouldQueue
     /**
      * Execute the job.
      */
-    public function handle(AsyncMarkdownSplitter $splitter): void
+    public function handle(): void
     {
         try {
             // 获取 Markdown 导入记录
@@ -59,93 +62,67 @@ class ProcessMarkdownSplit implements ShouldQueue
                 'error_message' => null,
             ]);
 
-            Log::info('Starting Markdown split (orchestrator)', [
-                'id' => $this->markdownImportId
+            Log::info('Starting Markdown pipeline (source->paper->part->question)', [
+                'id' => $this->markdownImportId,
             ]);
 
-            $blocks = $splitter->split($markdownImport->original_markdown);
-            $splitter->validate($blocks);
+            $fileParser = app(SourceFileParserService::class);
+            $paperExtractor = app(SourcePaperExtractorService::class);
+            $partExtractor = app(PaperPartExtractorService::class);
+            $questionExtractor = app(QuestionExtractorService::class);
+
+            // 建立 source_file
+            $sourceFile = $fileParser->storeFromMarkdown(
+                $markdownImport->file_name ?? ('import-' . $markdownImport->id . '.md'),
+                $markdownImport->original_markdown,
+                $markdownImport,
+                [],
+                null
+            );
+
+            // 拆分卷子和区块
+            $papers = $paperExtractor->extract($sourceFile);
+            $parts = collect();
+            foreach ($papers as $paper) {
+                $parts = $parts->merge($partExtractor->extract($paper));
+            }
 
-            Log::info('Markdown split completed', [
-                'id' => $this->markdownImportId,
-                'blocks_count' => count($blocks),
+            // 写入候选题
+            $markdownImport->update([
+                'progress_stage' => MarkdownImport::STAGE_WRITING,
+                'progress_message' => '写入拆题结果…',
+                'progress_updated_at' => now(),
+            ]);
+
+            PreQuestionCandidate::where('import_id', $this->markdownImportId)->update([
+                'status' => 'superseded',
             ]);
 
+            $sequence = 1;
+            $createdTotal = 0;
+            foreach ($parts as $part) {
+                $created = $questionExtractor->extractAndPersist($part, $markdownImport, $sequence);
+                $createdTotal += $created->count();
+            }
+
             $markdownImport->update([
-                'progress_total' => count($blocks),
+                'progress_total' => $createdTotal,
                 'progress_current' => 0,
                 'progress_updated_at' => now(),
             ]);
 
-            if (empty($blocks)) {
-                Log::warning('No candidates found from Markdown parsing', [
-                    'id' => $this->markdownImportId
-                ]);
+            if ($createdTotal === 0) {
                 $markdownImport->update([
                     'status' => 'failed',
                     'progress_stage' => MarkdownImport::STAGE_FAILED,
                     'progress_message' => '未解析出任何候选题',
                     'progress_updated_at' => now(),
                     'processing_finished_at' => now(),
-                    'error_message' => 'No candidates found'
+                    'error_message' => 'No candidates found',
                 ]);
                 return;
             }
 
-            Log::info('Markdown split done, seeding candidates to database', [
-                'id' => $this->markdownImportId,
-                'blocks_count' => count($blocks),
-            ]);
-
-            // 写入候选题到 pre_question_candidates 表(仅 raw_markdown + 顺序;AI 解析交给后续 batch job)
-            DB::beginTransaction();
-            try {
-                $markdownImport->update([
-                    'progress_stage' => MarkdownImport::STAGE_WRITING,
-                    'progress_message' => '写入拆题结果…',
-                    'progress_updated_at' => now(),
-                ]);
-
-                // 不删除历史数据:将旧记录标记为 superseded,避免重跑时混淆
-                PreQuestionCandidate::where('import_id', $this->markdownImportId)->update([
-                    'status' => 'superseded',
-                ]);
-
-                foreach ($blocks as $block) {
-                    $candidateIndex = (int) ($block['index'] ?? 0);
-                    $sequence = (int) ($block['sequence'] ?? 0);
-                    PreQuestionCandidate::updateOrCreate(
-                        [
-                            'import_id' => $this->markdownImportId,
-                            // 用 sequence 做唯一键,避免题号重复导致覆盖丢题
-                            'sequence' => $sequence,
-                        ],
-                        [
-                            'index' => $candidateIndex,
-                            'raw_markdown' => (string) ($block['raw_markdown'] ?? ''),
-                            'stem' => null,
-                            'options' => null,
-                            'images' => [],
-                            'tables' => [],
-                            'is_question_candidate' => false,
-                            'ai_confidence' => null,
-                            'status' => 'ai_pending',
-                        ]
-                    );
-                }
-
-                DB::commit();
-
-                Log::info('Successfully wrote candidates to pre_question_candidates', [
-                    'id' => $this->markdownImportId,
-                    'candidates_count' => count($blocks)
-                ]);
-
-            } catch (\Exception $e) {
-                DB::rollBack();
-                throw $e;
-            }
-
             // 进入并发 AI 解析阶段(方案 A:子 Job 批处理 + 多 worker 并行)
             $markdownImport->update([
                 'progress_stage' => MarkdownImport::STAGE_AI_PARSING,
@@ -154,7 +131,7 @@ class ProcessMarkdownSplit implements ShouldQueue
                 'progress_updated_at' => now(),
             ]);
 
-            $total = count($blocks);
+            $total = $createdTotal;
             $batchSize = 10; // 每批处理 10 题(并发由 worker 数控制)
             $batches = (int) ceil($total / $batchSize);
 

+ 32 - 0
app/Models/PreQuestionCandidate.php

@@ -5,6 +5,9 @@ namespace App\Models;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use App\Models\SourceFile;
+use App\Models\SourcePaper;
+use App\Models\PaperPart;
 
 class PreQuestionCandidate extends Model
 {
@@ -12,25 +15,39 @@ class PreQuestionCandidate extends Model
 
     protected $fillable = [
         'import_id',
+        'source_file_id',
+        'source_paper_id',
+        'part_id',
         'sequence',
         'index',
+        'question_number',
+        'order',
         'raw_markdown',
+        'clean_markdown',
+        'structured_json',
         'stem',
         'options',
         'images',
         'tables',
         'is_question_candidate',
         'ai_confidence',
+        'confidence',
+        'formula_detected',
+        'is_valid_question',
         'status',
     ];
 
     protected $casts = [
         'is_question_candidate' => 'boolean',
         'ai_confidence' => 'float',
+        'confidence' => 'decimal:2',
         'sequence' => 'integer',
+        'order' => 'integer',
         'options' => 'array',
         'images' => 'array',
         'tables' => 'array',
+        'formula_detected' => 'boolean',
+        'is_valid_question' => 'boolean',
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
     ];
@@ -46,6 +63,21 @@ class PreQuestionCandidate extends Model
         return $this->belongsTo(MarkdownImport::class, 'import_id');
     }
 
+    public function sourceFile(): BelongsTo
+    {
+        return $this->belongsTo(SourceFile::class, 'source_file_id');
+    }
+
+    public function sourcePaper(): BelongsTo
+    {
+        return $this->belongsTo(SourcePaper::class, 'source_paper_id');
+    }
+
+    public function part(): BelongsTo
+    {
+        return $this->belongsTo(PaperPart::class, 'part_id');
+    }
+
     public function getConfidenceBadgeAttribute(): string
     {
         if ($this->ai_confidence === null) {

+ 364 - 3
app/Services/MistakeBookService.php

@@ -45,6 +45,7 @@ class MistakeBookService
             'time_range' => $params['time_range'] ?? null,
             'start_date' => $params['start_date'] ?? null,
             'end_date' => $params['end_date'] ?? null,
+            'incorrect_only' => true, // ✅ 只获取错误记录(错题本的核心功能)
             'page' => $params['page'] ?? 1,
             'per_page' => $params['per_page'] ?? 20,
         ], fn ($value) => filled($value));
@@ -56,7 +57,23 @@ class MistakeBookService
             if ($response->successful()) {
                 info("MistakeBookService::listMistakes", [$response->json()]);
                 $body = $response->json();
-                return is_array($body) ? $body : ['data' => $body];
+                $result = is_array($body) ? $body : ['data' => $body];
+
+                // 补充知识点名称、技能信息和难度
+                $result = $this->enrichMistakeData($result);
+
+                // 获取统计数据
+                if (!empty($params['student_id'])) {
+                    $summary = $this->summarize($params['student_id']);
+                    $result['statistics'] = [
+                        'total_mistakes' => $summary['total'] ?? 0,
+                        'this_week' => $summary['this_week'] ?? 0,
+                        'pending_review' => $summary['pending_review'] ?? 0,
+                        'mastery_rate' => $summary['mastery_rate'] ?? 0.0,
+                    ];
+                }
+
+                return $result;
             }
 
             Log::warning('MistakeBook list failed', [
@@ -92,7 +109,12 @@ class MistakeBookService
 
             if ($response->successful()) {
                 $body = $response->json();
-                return is_array($body) ? $body : [];
+                $result = is_array($body) ? $body : [];
+
+                // 补充知识点名称和技能信息
+                $result = $this->enrichSingleMistakeData($result);
+
+                return $result;
             }
 
             Log::warning('Mistake detail request failed', [
@@ -216,7 +238,7 @@ class MistakeBookService
     }
 
     /**
-     * 标记已复习
+     * 标记已复习(向后兼容)
      */
     public function markReviewed(string $mistakeId): bool
     {
@@ -235,6 +257,110 @@ class MistakeBookService
         return false;
     }
 
+    /**
+     * 修改复习状态
+     *
+     * @param string $mistakeId 错题ID
+     * @param string $action 操作类型:'increment' 或 'reset'
+     * @param bool $forceReview 是否强制复习(仅对 increment 有效)
+     * @return array 复习状态信息
+     */
+    public function updateReviewStatus(string $mistakeId, string $action = 'increment', bool $forceReview = false): array
+    {
+        try {
+            $payload = array_filter([
+                'action' => $action,
+                'force_review' => $forceReview,
+            ]);
+
+            $response = Http::timeout($this->timeout)
+                ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/review-status', $payload);
+
+            if ($response->successful()) {
+                $body = $response->json();
+                return is_array($body) ? $body : [];
+            }
+
+            Log::warning('Update review status failed', [
+                'mistake_id' => $mistakeId,
+                'action' => $action,
+                'force_review' => $forceReview,
+                'status' => $response->status(),
+                'body' => $response->body(),
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('Update review status exception', [
+                'mistake_id' => $mistakeId,
+                'action' => $action,
+                'force_review' => $forceReview,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return [
+            'success' => false,
+            'error' => '更新复习状态失败',
+        ];
+    }
+
+    /**
+     * 获取复习状态
+     *
+     * @param string $mistakeId 错题ID
+     * @return array 复习状态信息
+     */
+    public function getReviewStatus(string $mistakeId): array
+    {
+        try {
+            $response = Http::timeout($this->timeout)
+                ->get($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/review-status');
+
+            if ($response->successful()) {
+                $body = $response->json();
+                return is_array($body) ? $body : [];
+            }
+
+            Log::warning('Get review status failed', [
+                'mistake_id' => $mistakeId,
+                'status' => $response->status(),
+                'body' => $response->body(),
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('Get review status exception', [
+                'mistake_id' => $mistakeId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return [
+            'success' => false,
+            'error' => '获取复习状态失败',
+        ];
+    }
+
+    /**
+     * 增加复习次数
+     *
+     * @param string $mistakeId 错题ID
+     * @param bool $forceReview 是否强制复习
+     * @return array 复习状态信息
+     */
+    public function incrementReviewCount(string $mistakeId, bool $forceReview = false): array
+    {
+        return $this->updateReviewStatus($mistakeId, 'increment', $forceReview);
+    }
+
+    /**
+     * 重置为强制复习状态
+     *
+     * @param string $mistakeId 错题ID
+     * @return array 复习状态信息
+     */
+    public function resetReviewStatus(string $mistakeId): array
+    {
+        return $this->updateReviewStatus($mistakeId, 'reset');
+    }
+
     /**
      * 添加到重练清单
      */
@@ -349,4 +475,239 @@ class MistakeBookService
 
         return null;
     }
+
+    /**
+     * 补充错题数据中的知识点名称、技能信息和难度
+     */
+    private function enrichMistakeData(array $result): array
+    {
+        Log::info('MistakeBookService::enrichMistakeData - Starting enrichment', [
+            'data_count' => count($result['data'] ?? []),
+        ]);
+
+        if (!isset($result['data']) || !is_array($result['data'])) {
+            Log::warning('MistakeBookService::enrichMistakeData - No data to enrich');
+            return $result;
+        }
+
+        // 获取所有知识点代码
+        $kpCodes = [];
+        foreach ($result['data'] as $item) {
+            if (!empty($item['question']['kp_code'])) {
+                $kpCodes[] = $item['question']['kp_code'];
+            }
+            if (!empty($item['kp_ids']) && is_array($item['kp_ids'])) {
+                $kpCodes = array_merge($kpCodes, $item['kp_ids']);
+            }
+        }
+        $kpCodes = array_unique(array_filter($kpCodes));
+
+        Log::info('MistakeBookService::enrichMistakeData - Found kp_codes', [
+            'kp_codes' => $kpCodes,
+        ]);
+
+        // 从KnowledgeService获取知识点详细信息
+        $kpDetailMap = [];
+        foreach ($kpCodes as $kpCode) {
+            $detail = $this->getKnowledgePointDetail($kpCode);
+            if (!empty($detail)) {
+                $kpDetailMap[$kpCode] = $detail;
+            }
+        }
+
+        Log::info('MistakeBookService::enrichMistakeData - Retrieved kp details', [
+            'kp_detail_map_keys' => array_keys($kpDetailMap),
+        ]);
+
+        // 补充数据
+        foreach ($result['data'] as &$item) {
+            // 补充知识点名称和技能信息
+            if (isset($item['question']['kp_code'])) {
+                $kpCode = $item['question']['kp_code'];
+                if (isset($kpDetailMap[$kpCode])) {
+                    $detail = $kpDetailMap[$kpCode];
+                    $item['question']['kp_name'] = $detail['cn_name'] ?? $detail['en_name'] ?? $kpCode;
+                    $item['question']['skills'] = array_column($detail['skills'] ?? [], 'skill_name');
+                    Log::info('MistakeBookService::enrichMistakeData - Enriched item', [
+                        'kp_code' => $kpCode,
+                        'kp_name' => $item['question']['kp_name'],
+                        'skills' => $item['question']['skills'],
+                    ]);
+                } else {
+                    // 如果没有找到详细信息,使用代码作为名称
+                    $item['question']['kp_name'] = $kpCode;
+                    $item['question']['skills'] = [];
+                    Log::warning('MistakeBookService::enrichMistakeData - No detail found for kp', [
+                        'kp_code' => $kpCode,
+                    ]);
+                }
+            }
+
+            // 确保kp_ids数组存在且不为空
+            if (!isset($item['kp_ids']) || !is_array($item['kp_ids'])) {
+                $item['kp_ids'] = [];
+            }
+
+            // 如果kp_ids为空但有kp_code,则添加
+            if (empty($item['kp_ids']) && !empty($item['question']['kp_code'])) {
+                $item['kp_ids'] = [$item['question']['kp_code']];
+            }
+
+            // 确保skill_ids数组存在
+            if (!isset($item['skill_ids']) || !is_array($item['skill_ids'])) {
+                $item['skill_ids'] = [];
+            }
+
+            // 如果skill_ids为空但有skills,则使用skills
+            if (empty($item['skill_ids']) && !empty($item['question']['skills'])) {
+                $item['skill_ids'] = $item['question']['skills'];
+            }
+
+            // 补充难度值(如果缺失)
+            if (!isset($item['question']['difficulty']) && isset($item['question']['kp_code'])) {
+                $kpCode = $item['question']['kp_code'];
+                if (isset($kpDetailMap[$kpCode])) {
+                    $detail = $kpDetailMap[$kpCode];
+                    // 使用importance作为难度(1-10)
+                    $item['question']['difficulty'] = ($detail['importance'] ?? 5) / 10.0;
+                    Log::info('MistakeBookService::enrichMistakeData - Set difficulty', [
+                        'kp_code' => $kpCode,
+                        'difficulty' => $item['question']['difficulty'],
+                        'importance' => $detail['importance'] ?? 5,
+                    ]);
+                }
+            }
+        }
+
+        Log::info('MistakeBookService::enrichMistakeData - Completed enrichment', [
+            'processed_count' => count($result['data']),
+        ]);
+
+        return $result;
+    }
+
+    /**
+     * 补充单条错题数据中的知识点名称、技能信息和难度
+     */
+    private function enrichSingleMistakeData(array $item): array
+    {
+        if (empty($item)) {
+            return $item;
+        }
+
+        // 从KnowledgeService获取知识点详细信息
+        $kpDetail = null;
+        if (isset($item['question']['kp_code'])) {
+            $kpCode = $item['question']['kp_code'];
+            $kpDetail = $this->getKnowledgePointDetail($kpCode);
+        }
+
+        // 补充知识点名称和技能信息
+        if (isset($item['question']['kp_code']) && $kpDetail) {
+            $item['question']['kp_name'] = $kpDetail['cn_name'] ?? $kpDetail['en_name'] ?? $item['question']['kp_code'];
+            $item['question']['skills'] = array_column($kpDetail['skills'] ?? [], 'skill_name');
+        } else {
+            if (isset($item['question']['kp_code'])) {
+                $item['question']['kp_name'] = $item['question']['kp_code'];
+            }
+            $item['question']['skills'] = [];
+        }
+
+        // 确保kp_ids数组存在且不为空
+        if (!isset($item['kp_ids']) || !is_array($item['kp_ids'])) {
+            $item['kp_ids'] = [];
+        }
+
+        // 如果kp_ids为空但有kp_code,则添加
+        if (empty($item['kp_ids']) && !empty($item['question']['kp_code'])) {
+            $item['kp_ids'] = [$item['question']['kp_code']];
+        }
+
+        // 确保skill_ids数组存在
+        if (!isset($item['skill_ids']) || !is_array($item['skill_ids'])) {
+            $item['skill_ids'] = [];
+        }
+
+        // 如果skill_ids为空但有skills,则使用skills
+        if (empty($item['skill_ids']) && !empty($item['question']['skills'])) {
+            $item['skill_ids'] = $item['question']['skills'];
+        }
+
+        // 补充难度值(如果缺失)
+        if (!isset($item['question']['difficulty']) && $kpDetail) {
+            // 使用importance作为难度(1-10)
+            $item['question']['difficulty'] = ($kpDetail['importance'] ?? 5) / 10.0;
+        }
+
+        return $item;
+    }
+
+    /**
+     * 从QuestionBank服务获取知识点详细信息
+     */
+    private function getKnowledgePointDetail(string $kpCode): ?array
+    {
+        Log::info('MistakeBookService::getKnowledgePointDetail - Fetching detail from QuestionBank', [
+            'kp_code' => $kpCode,
+        ]);
+
+        try {
+            // 获取QuestionBank中的题目来获取知识点信息
+            $response = Http::timeout($this->timeout)
+                ->get($this->questionBankBase . '/api/questions', [
+                    'kp_code' => $kpCode,
+                    'limit' => 1,
+                ]);
+
+            Log::info('MistakeBookService::getKnowledgePointDetail - Response from QuestionBank', [
+                'status' => $response->status(),
+                'has_body' => !empty($response->body()),
+            ]);
+
+            if ($response->successful()) {
+                $data = $response->json();
+                $questions = $data['data'] ?? [];
+                if (!empty($questions)) {
+                    $question = $questions[0];
+                    $detail = [
+                        'kp_code' => $kpCode,
+                        'cn_name' => $question['kp_name'] ?? $kpCode,
+                        'skills' => [],
+                        'importance' => null,
+                    ];
+
+                    // 提取技能信息
+                    if (!empty($question['skills'])) {
+                        foreach ($question['skills'] as $skillCode) {
+                            $detail['skills'][] = [
+                                'skill_code' => $skillCode,
+                                'skill_name' => $skillCode,
+                            ];
+                        }
+                    }
+
+                    // 从difficulty计算importance(0-1转为1-10)
+                    if (isset($question['difficulty'])) {
+                        $detail['importance'] = intval($question['difficulty'] * 10);
+                    }
+
+                    Log::info('MistakeBookService::getKnowledgePointDetail - Success', [
+                        'kp_code' => $kpCode,
+                        'kp_name' => $detail['cn_name'],
+                        'skills_count' => count($detail['skills']),
+                        'importance' => $detail['importance'],
+                    ]);
+
+                    return $detail;
+                }
+            }
+        } catch (\Throwable $e) {
+            Log::error('Get knowledge point detail from QuestionBank failed', [
+                'kp_code' => $kpCode,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return null;
+    }
 }

+ 206 - 0
routes/api.php

@@ -3,6 +3,7 @@
 use App\Http\Controllers\Api\IntelligentExamController;
 use App\Http\Controllers\Api\PreQuestionApiController;
 use App\Http\Controllers\Api\TextbookApiController;
+use App\Http\Controllers\Api\MistakeBookController;
 use App\Services\QuestionServiceApi;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Route;
@@ -451,6 +452,211 @@ Route::get('/exam-analysis/status/{taskId}', [ExamAnalysisApiController::class,
     ])
     ->name('api.exam-analysis.status');
 
+/*
+|--------------------------------------------------------------------------
+| 错题本 API 路由
+|--------------------------------------------------------------------------
+*/
+
+// 获取错题列表
+Route::get('/mistake-book', [MistakeBookController::class, 'listMistakes'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.list');
+
+// 获取单条错题详情
+Route::get('/mistake-book/{mistakeId}', [MistakeBookController::class, 'getMistakeDetail'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.detail');
+
+// 获取错题统计概要
+Route::get('/mistake-book/summary', [MistakeBookController::class, 'getSummary'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.summary');
+
+// 获取错误模式分析
+Route::get('/mistake-book/analytics/mistake-pattern', [MistakeBookController::class, 'getMistakePatterns'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.patterns');
+
+// 收藏/取消收藏错题
+Route::post('/mistake-book/{mistakeId}/favorite', [MistakeBookController::class, 'toggleFavorite'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.favorite');
+
+// 标记错题已复习
+Route::post('/mistake-book/{mistakeId}/review', [MistakeBookController::class, 'markReviewed'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.review');
+
+// 加入重练清单
+Route::post('/mistake-book/{mistakeId}/retry-list', [MistakeBookController::class, 'addToRetryList'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.retry-list');
+
+// 获取题目正确率
+Route::get('/analytics/question/{questionId}/accuracy', [MistakeBookController::class, 'getQuestionAccuracy'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.analytics.question.accuracy');
+
+// 推荐练习题
+Route::post('/mistake-book/recommend-practice', [MistakeBookController::class, 'recommendPractice'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.recommend-practice');
+
+// 获取错题本快照数据(仪表板用)
+Route::get('/mistake-book/snapshot', [MistakeBookController::class, 'getSnapshot'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.snapshot');
+
+/*
+|--------------------------------------------------------------------------
+| 错题复习状态管理 API 路由
+|--------------------------------------------------------------------------
+*/
+Route::post('/mistake-book/{mistakeId}/review-status', [MistakeBookController::class, 'updateReviewStatus'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.review-status.update');
+
+Route::get('/mistake-book/{mistakeId}/review-status', [MistakeBookController::class, 'getReviewStatus'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.review-status.get');
+
+Route::post('/mistake-book/{mistakeId}/increment-review', [MistakeBookController::class, 'incrementReview'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.increment-review');
+
+Route::post('/mistake-book/{mistakeId}/reset-review', [MistakeBookController::class, 'resetReview'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.mistake-book.reset-review');
+
+/*
+|--------------------------------------------------------------------------
+| 知识点掌握情况 API 路由
+|--------------------------------------------------------------------------
+*/
+
+use App\Http\Controllers\Api\KnowledgeMasteryController;
+
+// 获取学生知识点掌握情况统计
+Route::get('/knowledge-mastery/stats/{studentId}', [KnowledgeMasteryController::class, 'stats'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.knowledge-mastery.stats');
+
+// 获取学生知识点掌握摘要
+Route::get('/knowledge-mastery/summary/{studentId}', [KnowledgeMasteryController::class, 'summary'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.knowledge-mastery.summary');
+
+// 获取学生知识点图谱数据
+Route::get('/knowledge-mastery/graph/{studentId}', [KnowledgeMasteryController::class, 'graph'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.knowledge-mastery.graph');
+
+// 获取学生知识点图谱快照列表
+Route::get('/knowledge-mastery/graph/snapshots/{studentId}', [KnowledgeMasteryController::class, 'graphSnapshots'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.knowledge-mastery.graph.snapshots');
+
+// 创建知识点掌握度快照
+Route::post('/knowledge-mastery/snapshot/{studentId}', [KnowledgeMasteryController::class, 'createSnapshot'])
+    ->withoutMiddleware([
+        Authenticate::class,
+        'auth',
+        'auth:sanctum',
+        'auth:api',
+    ])
+    ->name('api.knowledge-mastery.snapshot.create');
+
 /*
 |--------------------------------------------------------------------------
 | 教材管理 API 路由