Explorar el Código

chore: add exam analysis backfill command

yemeishu hace 2 semanas
padre
commit
59ae5a0465
Se han modificado 2 ficheros con 336 adiciones y 0 borrados
  1. 335 0
      app/Console/Commands/BackfillExamAnalysisResultsCommand.php
  2. 1 0
      bootstrap/app.php

+ 335 - 0
app/Console/Commands/BackfillExamAnalysisResultsCommand.php

@@ -0,0 +1,335 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\GenerateAnalysisPdfJob;
+use App\Jobs\ProcessExamAnswerAnalysisJob;
+use App\Services\TaskManager;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class BackfillExamAnalysisResultsCommand extends Command
+{
+    private const ACTION_SKIP = 'skip';
+    private const ACTION_ANALYSIS = 'analysis';
+    private const ACTION_PDF_ONLY = 'pdf_only';
+    private const ACTION_SYNC_PDF_URL = 'sync_pdf_url';
+
+    protected $signature = 'exam:backfill-analysis-results
+                            {--since= : papers.completed_at >= 该时间(默认今天 00:00:00)}
+                            {--paper= : 仅处理指定 paper_id}
+                            {--student= : 仅处理指定 student_id}
+                            {--limit=100 : 最多处理多少条候选试卷}
+                            {--dry-run : 只打印待回填列表,不执行回填}';
+
+    protected $description = '回填 exam_analysis_results 缺失分析数据或缺失 PDF URL 的试卷';
+
+    public function handle(TaskManager $taskManager): int
+    {
+        $sinceRaw = (string) ($this->option('since') ?: Carbon::today()->toDateTimeString());
+        try {
+            $since = Carbon::parse($sinceRaw);
+        } catch (Throwable) {
+            $this->error('无效的 --since 时间:'.$sinceRaw);
+
+            return self::FAILURE;
+        }
+
+        $limit = max(1, (int) $this->option('limit'));
+        $paperFilter = $this->option('paper');
+        $studentFilter = $this->option('student');
+        $isDryRun = (bool) $this->option('dry-run');
+
+        $paperRows = DB::connection('mysql')
+            ->table('papers')
+            ->whereNotNull('completed_at')
+            ->where('completed_at', '>=', $since->toDateTimeString())
+            ->whereNotNull('student_id')
+            ->where('student_id', '!=', '')
+            ->when($paperFilter, fn ($q) => $q->where('paper_id', $paperFilter))
+            ->when($studentFilter, fn ($q) => $q->where('student_id', $studentFilter))
+            ->orderBy('completed_at')
+            ->limit($limit)
+            ->get(['paper_id', 'student_id', 'completed_at']);
+
+        $targets = [];
+        $counts = [
+            self::ACTION_SKIP => 0,
+            self::ACTION_ANALYSIS => 0,
+            self::ACTION_PDF_ONLY => 0,
+            self::ACTION_SYNC_PDF_URL => 0,
+        ];
+
+        foreach ($paperRows as $paper) {
+            $latest = DB::connection('mysql')
+                ->table('exam_analysis_results')
+                ->where('paper_id', $paper->paper_id)
+                ->where('student_id', $paper->student_id)
+                ->orderByDesc('created_at')
+                ->first(['id', 'analysis_data', 'analysis_pdf_url']);
+
+            $studentReportPdfUrl = DB::connection('mysql')
+                ->table('student_reports')
+                ->where('paper_id', $paper->paper_id)
+                ->where('student_id', $paper->student_id)
+                ->where('report_type', 'exam_analysis')
+                ->whereNotNull('pdf_url')
+                ->where('pdf_url', '!=', '')
+                ->orderByDesc('updated_at')
+                ->value('pdf_url');
+
+            $action = $this->resolveBackfillAction(
+                $latest->analysis_data ?? null,
+                $latest->id ?? null,
+                $latest->analysis_pdf_url ?? null,
+                $studentReportPdfUrl
+            );
+
+            $counts[$action]++;
+            if ($action === self::ACTION_SKIP) {
+                continue;
+            }
+
+            $targets[] = (object) [
+                'paper_id' => (string) $paper->paper_id,
+                'student_id' => (string) $paper->student_id,
+                'completed_at' => (string) $paper->completed_at,
+                'action' => $action,
+                'analysis_id' => $latest->id ?? null,
+                'student_report_pdf_url' => $studentReportPdfUrl,
+            ];
+        }
+
+        $this->info(sprintf(
+            '扫描完成:已完成试卷 %d 条,需补分析 %d 条,仅补PDF %d 条,同步PDF URL %d 条,已跳过 %d 条(since=%s, limit=%d)',
+            $paperRows->count(),
+            $counts[self::ACTION_ANALYSIS],
+            $counts[self::ACTION_PDF_ONLY],
+            $counts[self::ACTION_SYNC_PDF_URL],
+            $counts[self::ACTION_SKIP],
+            $since->toDateTimeString(),
+            $limit
+        ));
+
+        if ($isDryRun) {
+            foreach ($targets as $item) {
+                $this->line(sprintf(
+                    '[dry-run] action=%s paper_id=%s student_id=%s completed_at=%s analysis_id=%s',
+                    $item->action,
+                    $item->paper_id,
+                    $item->student_id,
+                    $item->completed_at,
+                    $item->analysis_id ?? 'null'
+                ));
+            }
+
+            return self::SUCCESS;
+        }
+
+        $ok = 0;
+        $failed = 0;
+        foreach ($targets as $item) {
+            try {
+                if ($item->action === self::ACTION_ANALYSIS) {
+                    $questions = $this->buildQuestionsFromPaperQuestions($item->paper_id);
+                    if ($questions === []) {
+                        $this->warn("回填失败:{$item->paper_id} 无可用 paper_questions");
+                        $failed++;
+                        continue;
+                    }
+
+                    $examData = [
+                        'paper_id' => $item->paper_id,
+                        'student_id' => $item->student_id,
+                        'questions' => $questions,
+                        'force_recalculate' => true,
+                    ];
+
+                    $taskId = $taskManager->createTask(TaskManager::TASK_TYPE_ANALYSIS, [
+                        'type' => 'exam_answer_analysis_backfill',
+                        'paper_id' => $item->paper_id,
+                        'student_id' => $item->student_id,
+                        'question_count' => count($questions),
+                    ]);
+
+                    dispatch(new ProcessExamAnswerAnalysisJob($taskId, $examData));
+                    $this->info("已投递补分析任务:{$item->paper_id} / {$item->student_id} task_id={$taskId}");
+                    $ok++;
+                    continue;
+                }
+
+                if ($item->action === self::ACTION_SYNC_PDF_URL) {
+                    DB::connection('mysql')
+                        ->table('exam_analysis_results')
+                        ->where('id', $item->analysis_id)
+                        ->update([
+                            'analysis_pdf_url' => $item->student_report_pdf_url,
+                            'updated_at' => now(),
+                        ]);
+
+                    $this->info("已同步PDF URL:{$item->paper_id} / {$item->student_id}");
+                    $ok++;
+                    continue;
+                }
+
+                dispatch(new GenerateAnalysisPdfJob(
+                    $item->paper_id,
+                    $item->student_id,
+                    $item->analysis_id ? (string) $item->analysis_id : null
+                ));
+                $this->info("已投递补PDF任务:{$item->paper_id} / {$item->student_id}");
+                $ok++;
+            } catch (Throwable $e) {
+                $this->error("回填失败:{$item->paper_id} / {$item->student_id} -> ".$e->getMessage());
+                Log::error('BackfillExamAnalysisResultsCommand: 回填失败', [
+                    'paper_id' => $item->paper_id,
+                    'student_id' => $item->student_id,
+                    'action' => $item->action,
+                    'error' => $e->getMessage(),
+                    'exception' => get_class($e),
+                ]);
+                $failed++;
+            }
+        }
+
+        $this->info("回填投递完成:成功 {$ok},失败 {$failed}");
+
+        return $failed > 0 ? self::FAILURE : self::SUCCESS;
+    }
+
+    private function resolveBackfillAction(
+        mixed $analysisDataRaw,
+        mixed $analysisId,
+        mixed $analysisPdfUrl,
+        mixed $studentReportPdfUrl
+    ): string
+    {
+        $hasValidAnalysis = $this->hasValidAnalysisData($analysisDataRaw, $analysisId);
+        $hasAnalysisPdf = $this->hasNonEmptyString($analysisPdfUrl);
+        $hasStudentReportPdf = $this->hasNonEmptyString($studentReportPdfUrl);
+
+        if (! $hasValidAnalysis) {
+            return self::ACTION_ANALYSIS;
+        }
+
+        if ($hasAnalysisPdf) {
+            return self::ACTION_SKIP;
+        }
+
+        return $hasStudentReportPdf ? self::ACTION_SYNC_PDF_URL : self::ACTION_PDF_ONLY;
+    }
+
+    private function hasValidAnalysisData(mixed $analysisDataRaw, mixed $analysisId): bool
+    {
+        if ($analysisId === null || $analysisDataRaw === null) {
+            return false;
+        }
+
+        $raw = is_string($analysisDataRaw) ? trim($analysisDataRaw) : '';
+        if ($raw === '') {
+            return false;
+        }
+
+        $decoded = json_decode($raw, true);
+        if (! is_array($decoded)) {
+            return false;
+        }
+
+        $hasQuestionAnalysis = isset($decoded['question_analysis']) && is_array($decoded['question_analysis']) && $decoded['question_analysis'] !== [];
+        $hasOverallSummary = isset($decoded['overall_summary']) && is_array($decoded['overall_summary']) && $decoded['overall_summary'] !== [];
+
+        return $hasQuestionAnalysis || $hasOverallSummary;
+    }
+
+    private function hasNonEmptyString(mixed $value): bool
+    {
+        return is_string($value) && trim($value) !== '';
+    }
+
+    /**
+     * @return list<array<string, mixed>>
+     */
+    private function buildQuestionsFromPaperQuestions(string $paperId): array
+    {
+        $rows = DB::connection('mysql')
+            ->table('paper_questions')
+            ->where('paper_id', $paperId)
+            ->orderBy('question_number')
+            ->get([
+                'question_id',
+                'question_bank_id',
+                'score',
+                'score_obtained',
+                'student_answer',
+                'is_correct',
+                'teacher_comment',
+            ]);
+
+        $questions = [];
+        foreach ($rows as $row) {
+            $qid = $row->question_bank_id ?? $row->question_id ?? null;
+            if ($qid === null || $qid === '') {
+                continue;
+            }
+
+            $item = [
+                'question_id' => (string) $qid,
+                'score' => isset($row->score) ? (float) $row->score : 2.0,
+                'is_correct' => $this->normalizeIsCorrect($row->is_correct),
+                'student_answer' => $row->student_answer,
+                'teacher_comment' => $row->teacher_comment,
+            ];
+
+            if ($row->score_obtained !== null) {
+                $item['score_obtained'] = (float) $row->score_obtained;
+            }
+
+            $questions[] = $item;
+        }
+
+        return $questions;
+    }
+
+    /**
+     * @return list<int>
+     */
+    private function normalizeIsCorrect(mixed $value): array
+    {
+        if (is_array($value)) {
+            return $this->flattenToBinary($value);
+        }
+
+        if (is_string($value)) {
+            $trimmed = trim($value);
+            if ($trimmed !== '' && ($trimmed[0] === '[' || $trimmed[0] === '{')) {
+                $decoded = json_decode($trimmed, true);
+                if (is_array($decoded)) {
+                    return $this->flattenToBinary($decoded);
+                }
+            }
+        }
+
+        if ($value === null || $value === '') {
+            return [0];
+        }
+
+        return [((bool) $value) ? 1 : 0];
+    }
+
+    /**
+     * @param  array<int, mixed>  $values
+     * @return list<int>
+     */
+    private function flattenToBinary(array $values): array
+    {
+        $out = [];
+        foreach ($values as $v) {
+            $out[] = ((bool) $v) ? 1 : 0;
+        }
+
+        return $out === [] ? [0] : $out;
+    }
+}

+ 1 - 0
bootstrap/app.php

@@ -16,6 +16,7 @@ return Application::configure(basePath: dirname(__DIR__))
         \App\Console\Commands\ImportPdfCommand::class,
         \App\Console\Commands\ImportPdfCommand::class,
         \App\Console\Commands\RebuildKnowledgeStatsCommand::class,
         \App\Console\Commands\RebuildKnowledgeStatsCommand::class,
         \App\Console\Commands\BackfillQuestionMetaCommand::class,
         \App\Console\Commands\BackfillQuestionMetaCommand::class,
+        \App\Console\Commands\BackfillExamAnalysisResultsCommand::class,
         \App\Console\Commands\SyncQuestionAssetsCommand::class,
         \App\Console\Commands\SyncQuestionAssetsCommand::class,
         \App\Console\Commands\SyncQuestionsFromQuestionBank::class,
         \App\Console\Commands\SyncQuestionsFromQuestionBank::class,
         \App\Console\Commands\GenerateJudgeCardTemplateCommand::class,
         \App\Console\Commands\GenerateJudgeCardTemplateCommand::class,