Selaa lähdekoodia

Merge branch 'main' into ye/optimize-analysis-report-v3

# Conflicts:
#	app/Services/ExamPdfExportService.php
yemeishu 3 viikkoa sitten
vanhempi
commit
ecb81fdcc0
26 muutettua tiedostoa jossa 3609 lisäystä ja 156 poistoa
  1. 4 0
      .gitignore
  2. 269 0
      app/Console/Commands/AnalyzeQuestionDifficultyCalibrationCommand.php
  3. 11 15
      app/Http/Controllers/Api/QuestionPdfController.php
  4. 608 0
      app/Services/Analytics/QuestionDifficultyCalibrationAnalyzer.php
  5. 1096 0
      app/Services/Analytics/QuestionDifficultyCalibrationService.php
  6. 438 29
      app/Services/DiagnosticChapterService.php
  7. 25 13
      app/Services/ExamAnswerAnalysisService.php
  8. 325 3
      app/Services/ExamPdfExportService.php
  9. 8 2
      app/Services/ExamTypeStrategy.php
  10. 88 0
      app/Services/QuestionDifficultyResolver.php
  11. 24 7
      app/Services/QuestionLocalService.php
  12. 66 12
      app/Support/GradingMarkBoxCounter.php
  13. 9 29
      app/Support/OptionLayoutDecider.php
  14. 1 0
      bootstrap/app.php
  15. 26 0
      database/migrations/2026_04_15_223000_create_pdf_image_metrics_table.php
  16. 38 0
      database/migrations/2026_04_16_120000_create_question_difficulty_calibrations_table.php
  17. 19 1
      resources/views/components/exam/paper-body.blade.php
  18. 3 1
      resources/views/pdf/exam-grading.blade.php
  19. 4 38
      resources/views/pdf/exam-paper.blade.php
  20. 3 1
      resources/views/pdf/partials/common-styles.blade.php
  21. 19 3
      resources/views/pdf/partials/grading-scan-sheet.blade.php
  22. 127 0
      resources/views/pdf/partials/paper-body-core-styles.blade.php
  23. 195 0
      resources/views/pdf/partials/question-check-page.blade.php
  24. 110 0
      resources/views/pdf/partials/question-check-scan-sheet.blade.php
  25. 90 0
      resources/views/pdf/question-check.blade.php
  26. 3 2
      tests/Unit/OptionLayoutDeciderTest.php

+ 4 - 0
.gitignore

@@ -45,3 +45,7 @@ ansible/
 # 向量加粗修复脚本(独立工具)
 scripts/vector_bold_fix/
 .serena/
+
+# Local planning/assistant workspace artifacts
+.planning/
+.claude/

+ 269 - 0
app/Console/Commands/AnalyzeQuestionDifficultyCalibrationCommand.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Question;
+use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\File;
+
+class AnalyzeQuestionDifficultyCalibrationCommand extends Command
+{
+    protected $signature = 'questions:difficulty-calibration-report
+                            {--min-attempts=5 : 每题至少多少次已判分作答才纳入}
+                            {--since= : 仅统计该日期之后(含),Y-m-d}
+                            {--no-mistakes : 不合并 mistake_records}
+                            {--calibration-min-attempts=10 : 动态调整生效的最小有效样本(硬约束)}
+                            {--alpha=0.2 : 平滑系数(硬约束)}
+                            {--max-step=0.03 : 单次调整最大幅度(硬约束)}
+                            {--half-life-days=30 : 时间衰减半衰期(天,硬约束)}
+                            {--student= : 只统计该 student_id(papers.student_id)下的作答}
+                            {--question-bank-id= : 只分析该题库主键 id(questions.id)}
+                            {--question-code= : 只分析该题编码(questions.question_code)}
+                            {--limit=80 : 终端打印多少行具体题目;0 表示全部}
+                            {--sort=attempts : 排序:attempts|gap_desc|gap_asc(gap=实测错误率−题库难度0~1)}
+                            {--with-stem : 为每行截取题干前 60 字(仅建议与 --limit 联用)}
+                            {--with-aggregate : 额外打印 Pearson、分箱等汇总}
+                            {--json= : 完整 JSON 路径}
+                            {--csv= : per_question CSV 路径}';
+
+    protected $description = '按「每一道做过的题」输出具体次数、对错、题库难度等(paper_questions 聚合)';
+
+    public function handle(QuestionDifficultyCalibrationAnalyzer $analyzer): int
+    {
+        $since = $this->option('since')
+            ? Carbon::parse((string) $this->option('since'))->startOfDay()
+            : null;
+
+        $qbId = $this->option('question-bank-id');
+        $minAttempts = (int) $this->option('min-attempts');
+        if ($qbId !== null && $qbId !== '' && $minAttempts === 5) {
+            $minAttempts = 1;
+        }
+
+        $report = $analyzer->run([
+            'min_attempts' => $minAttempts,
+            'since' => $since,
+            'include_mistakes' => ! $this->option('no-mistakes'),
+            'calibration_min_attempts' => (int) $this->option('calibration-min-attempts'),
+            'alpha' => (float) $this->option('alpha'),
+            'max_step' => (float) $this->option('max-step'),
+            'half_life_days' => (int) $this->option('half-life-days'),
+            'student_id' => $this->option('student'),
+            'question_bank_id' => $qbId !== null && $qbId !== '' ? (int) $qbId : null,
+            'question_code' => $this->option('question-code') ? (string) $this->option('question-code') : null,
+        ]);
+
+        if (! ($report['ok'] ?? false)) {
+            $this->error($report['error'] ?? '分析失败');
+
+            return self::FAILURE;
+        }
+
+        $rows = collect($report['per_question'] ?? []);
+
+        $meta = $report['meta'] ?? [];
+        $this->line('筛选条件: min_attempts='.($meta['min_attempts'] ?? '')
+            .($meta['since'] ? ' since='.$meta['since'] : '')
+            .($meta['student_id'] ? ' student_id='.$meta['student_id'] : '')
+            .($meta['question_bank_id'] ? ' question_bank_id='.$meta['question_bank_id'] : ''));
+        $constraints = $meta['calibration_constraints'] ?? [];
+        if ($constraints !== []) {
+            $this->line(
+                '动态约束: stratified_by='.$constraints['stratified_by']
+                .' min='.$constraints['min_attempts']
+                .' alpha='.$constraints['alpha']
+                .' max_step='.$constraints['max_step']
+                .' half_life_days='.$constraints['time_decay_half_life_days']
+            );
+        }
+        $this->line('命中题数(聚合行数): '.($meta['question_rows'] ?? $rows->count()));
+        $this->newLine();
+
+        $sort = (string) $this->option('sort');
+        $rows = match ($sort) {
+            'gap_desc' => $rows->sortByDesc(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(),
+            'gap_asc' => $rows->sortBy(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(),
+            default => $rows->sortByDesc('attempts')->values(),
+        };
+
+        $withStem = (bool) $this->option('with-stem');
+        $stemById = [];
+        if ($withStem && $rows->isNotEmpty()) {
+            $ids = $rows->pluck('question_bank_id')->unique()->filter()->all();
+            $stemById = DB::table('questions')
+                ->whereIn('id', $ids)
+                ->pluck('stem', 'id')
+                ->all();
+        }
+
+        $limit = (int) $this->option('limit');
+        $slice = $limit === 0 ? $rows : $rows->take($limit);
+
+        $tableRows = $slice->map(function (array $r) use ($withStem, $stemById) {
+            $norm = $r['bank_difficulty_normalized'];
+            $emp = $r['empirical_error_rate'];
+            $line = [
+                (string) ($r['question_bank_id'] ?? ''),
+                (string) ($r['question_code'] ?? ''),
+                (string) ($r['attempts'] ?? ''),
+                (string) ($r['correct_count'] ?? ''),
+                (string) ($r['wrong_count'] ?? ''),
+                $r['accuracy'] !== null ? (string) $r['accuracy'] : '',
+                $r['bank_difficulty'] !== null ? (string) $r['bank_difficulty'] : '',
+                $norm !== null ? (string) $norm : '',
+                $r['avg_paper_question_difficulty'] !== null ? (string) round((float) $r['avg_paper_question_difficulty'], 4) : '',
+                $emp !== null ? (string) round((float) $emp, 4) : '',
+                $r['calibration_gap'] !== null ? (string) $r['calibration_gap'] : '',
+                isset($r['calibration_weighted_error_rate']) ? (string) $r['calibration_weighted_error_rate'] : '',
+                isset($r['calibration_effective_attempts']) ? (string) $r['calibration_effective_attempts'] : '',
+                (string) (($r['calibration_recommendation']['action'] ?? 'hold')),
+                isset($r['calibration_recommendation']['delta']) ? (string) $r['calibration_recommendation']['delta'] : '',
+                isset($r['calibration_recommendation']['suggested_difficulty']) ? (string) $r['calibration_recommendation']['suggested_difficulty'] : '',
+                (string) ($r['mistake_records_count'] ?? '0'),
+            ];
+            if ($withStem) {
+                $id = (int) ($r['question_bank_id'] ?? 0);
+                $raw = $stemById[$id] ?? '';
+                $text = $raw !== '' ? mb_substr(trim(strip_tags($raw)), 0, 60) : '';
+                $line[] = $text;
+            }
+
+            return $line;
+        })->all();
+
+        $headers = [
+            '题库id',
+            'question_code',
+            '作答次数',
+            '对',
+            '错',
+            '正确率',
+            '题库difficulty(原值)',
+            '题库难度(0~1)',
+            '卷面难度均值',
+            '实测错误率',
+            '标定-实测差(gap)',
+            '分层时衰错误率',
+            '有效样本(时衰)',
+            '建议动作',
+            '建议delta',
+            '建议新难度',
+            '错题本行数',
+        ];
+        if ($withStem) {
+            $headers[] = '题干前60字';
+        }
+
+        $this->info('【每一道做过且已判分的题】——以下为聚合结果(同一题库 id 跨所有相关试卷合并)');
+        $this->table($headers, $tableRows);
+        if ($limit > 0 && $rows->count() > $limit) {
+            $this->comment('仅显示前 '.$limit.' 行,共 '.$rows->count().' 行;加 --limit=0 可输出全部(建议配合 --csv)。');
+        }
+        $this->newLine();
+
+        $qb = $meta['question_bank_id'] ?? null;
+        if ($qb !== null) {
+            $q = Question::query()->find($qb);
+            if ($q) {
+                $this->info('【本题题干节选】question_bank_id='.$qb);
+                $this->line(mb_substr(trim(strip_tags((string) $q->stem)), 0, 400));
+                $this->newLine();
+            }
+        }
+
+        if ($this->option('with-aggregate')) {
+            $s = $report['summary'] ?? [];
+            $this->line('Pearson(题库0~1难度 vs 实测错误率): '.json_encode($s['pearson_bank_difficulty_vs_empirical_error_rate'] ?? null));
+            $this->line($s['interpretation'] ?? '');
+            $this->line('Pearson(学案档位 vs 单次是否错): '.json_encode($s['pearson_paper_difficulty_category_vs_incorrect'] ?? null));
+            $paper = $report['paper_difficulty_category_vs_incorrect_rate'] ?? [];
+            $this->table(
+                ['学案档位(0-4)', '条数', '错误率'],
+                collect($paper['by_category'] ?? [])->map(fn ($r) => [
+                    $r['difficulty_category_numeric'] ?? 'unknown',
+                    $r['n'],
+                    $r['incorrect_rate'],
+                ])->all()
+            );
+            $this->table(
+                ['bin_min', 'bin_max', '题数', '总作答', '平均正确率'],
+                collect($report['bins_by_bank_difficulty'] ?? [])->map(fn ($b) => [
+                    $b['min'], $b['max'], $b['n_questions'], $b['total_attempts'], $b['mean_accuracy'],
+                ])->all()
+            );
+            $this->newLine();
+        }
+
+        $jsonPath = $this->option('json');
+        if ($jsonPath) {
+            File::put($jsonPath, json_encode($report, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+            $this->info('JSON: '.$jsonPath);
+        }
+
+        $csvPath = $this->option('csv');
+        if ($csvPath) {
+            $this->writeCsv($csvPath, $report['per_question'] ?? []);
+            $this->info('CSV: '.$csvPath);
+        }
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @param  list<array<string, mixed>>  $rows
+     */
+    private function writeCsv(string $path, array $rows): void
+    {
+        $fh = fopen($path, 'wb');
+        if ($fh === false) {
+            $this->error('无法写入 CSV');
+
+            return;
+        }
+        $headers = [
+            'question_bank_id',
+            'question_code',
+            'attempts',
+            'correct_count',
+            'wrong_count',
+            'accuracy',
+            'bank_difficulty',
+            'bank_difficulty_normalized',
+            'avg_paper_question_difficulty',
+            'empirical_error_rate',
+            'calibration_gap',
+            'calibration_weighted_error_rate',
+            'calibration_effective_attempts',
+            'recommended_action',
+            'recommended_delta',
+            'suggested_difficulty',
+            'mistake_records_count',
+        ];
+        fputcsv($fh, $headers);
+        foreach ($rows as $r) {
+            fputcsv($fh, [
+                $r['question_bank_id'] ?? '',
+                $r['question_code'] ?? '',
+                $r['attempts'] ?? '',
+                $r['correct_count'] ?? '',
+                $r['wrong_count'] ?? '',
+                $r['accuracy'] ?? '',
+                $r['bank_difficulty'] ?? '',
+                $r['bank_difficulty_normalized'] ?? '',
+                $r['avg_paper_question_difficulty'] ?? '',
+                $r['empirical_error_rate'] ?? '',
+                $r['calibration_gap'] ?? '',
+                $r['calibration_weighted_error_rate'] ?? '',
+                $r['calibration_effective_attempts'] ?? '',
+                $r['calibration_recommendation']['action'] ?? '',
+                $r['calibration_recommendation']['delta'] ?? '',
+                $r['calibration_recommendation']['suggested_difficulty'] ?? '',
+                $r['mistake_records_count'] ?? '',
+            ]);
+        }
+        fclose($fh);
+    }
+}

+ 11 - 15
app/Http/Controllers/Api/QuestionPdfController.php

@@ -39,7 +39,7 @@ class QuestionPdfController extends Controller
         $validator = Validator::make($request->all(), [
             'question_ids' => 'required|array|min:1|max:100',
             'question_ids.*' => 'required|integer',
-            'student_id' => 'required|string',
+            'student_id' => 'nullable|string',
             'student_name' => 'nullable|string|max:50',
             'student_grade' => 'nullable|string|max:20',
             'teacher_name' => 'nullable|string|max:50',
@@ -57,12 +57,11 @@ class QuestionPdfController extends Controller
         }
 
         $questionIds = $request->input('question_ids');
-        $studentId = $request->input('student_id');
-        $studentName = $request->input('student_name', '');
-        $studentGrade = $request->input('student_grade', '');
-        $teacherName = $request->input('teacher_name', '');
+        $studentId = (string) $request->input('student_id', 'question_check');
+        $studentName = $request->input('student_name', '________');
+        $studentGrade = $request->input('student_grade', '________');
+        $teacherName = $request->input('teacher_name', '________');
         $paperName = $request->input('paper_name', '专项练习');
-        $includeGrading = $request->input('include_grading', false);
         $source = $request->input('source', 'default'); // 题库来源:default=questions_tem, ai=questions_ai, main=questions
 
         Log::info('生成指定题目PDF', [
@@ -98,21 +97,18 @@ class QuestionPdfController extends Controller
             // 3. Build virtual paper structure
             $paper = $this->buildVirtualPaper($paperName, $studentId, $groupedQuestions);
 
-            // 4. Generate PDF
-            $result = $this->pdfService->generateByQuestions(
+            // 4. 生成题目质检PDF(固定判题卡体系样式,不走正常组卷路径)
+            $result = $this->pdfService->generateQuestionCheckPdf(
                 $paper,
                 $groupedQuestions,
                 [
                     'name' => $studentName,
                     'grade' => $studentGrade,
                 ],
-                [
-                    'name' => $teacherName,
-                ],
-                $includeGrading
+                ['name' => $teacherName]
             );
 
-            Log::info('指定题目PDF生成成功', [
+            Log::info('题目质检PDF生成成功', [
                 'student_id' => $studentId,
                 'question_count' => count($questionIds),
                 'pdf_url' => $result['pdf_url'] ?? null,
@@ -120,7 +116,7 @@ class QuestionPdfController extends Controller
 
             return response()->json([
                 'success' => true,
-                'message' => 'PDF生成成功',
+                'message' => '题目质检PDF生成成功',
                 'data' => $result,
             ]);
 
@@ -242,7 +238,7 @@ class QuestionPdfController extends Controller
         }
 
         // Generate unique paper ID
-        $paperId = 'custom_' . $studentId . '_' . time() . '_' . uniqid();
+        $paperId = $studentId . '_' . time() . '_' . uniqid();
 
         return (object) [
             'paper_id' => $paperId,

+ 608 - 0
app/Services/Analytics/QuestionDifficultyCalibrationAnalyzer.php

@@ -0,0 +1,608 @@
+<?php
+
+namespace App\Services\Analytics;
+
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 从做题与错题数据抽取「题库标定难度 vs 实测正确率」等指标,用于检验难度体系是否合理。
+ *
+ * 说明:当前库表未见独立「学生逐题自评难易」字段;{@see self::parsePaperDifficultyCategory()}
+ * 将 papers.difficulty_category 解析为数值,作为「本次练习/学案侧难度选择」的代理变量。
+ */
+class QuestionDifficultyCalibrationAnalyzer
+{
+    /**
+     * @param  array{
+     *     min_attempts?: int,
+     *     since?: Carbon|null,
+     *     include_mistakes?: bool,
+     *     student_id?: string|int|null,
+     *     question_bank_id?: int|null,
+     *     question_code?: string|null,
+     *     calibration_min_attempts?: int,
+     *     alpha?: float,
+     *     max_step?: float,
+     *     half_life_days?: int
+     * }  $options
+     * @return array<string, mixed>
+     */
+    public function run(array $options = []): array
+    {
+        $minAttempts = max(1, (int) ($options['min_attempts'] ?? 5));
+        $since = $options['since'] ?? null;
+        $includeMistakes = (bool) ($options['include_mistakes'] ?? true);
+        $studentId = isset($options['student_id']) && $options['student_id'] !== '' && $options['student_id'] !== null
+            ? (string) $options['student_id']
+            : null;
+        $questionBankId = isset($options['question_bank_id']) ? (int) $options['question_bank_id'] : null;
+        if ($questionBankId === 0) {
+            $questionBankId = null;
+        }
+        $questionCode = isset($options['question_code']) ? trim((string) $options['question_code']) : '';
+        if ($questionCode !== '' && Schema::hasTable('questions')) {
+            $resolved = DB::table('questions')->where('question_code', $questionCode)->value('id');
+            if ($resolved === null) {
+                return [
+                    'ok' => false,
+                    'error' => '未找到 question_code='.$questionCode.' 对应的题库题目',
+                ];
+            }
+            $questionBankId = (int) $resolved;
+        }
+
+        if (! Schema::hasTable('paper_questions') || ! Schema::hasTable('papers')) {
+            return [
+                'ok' => false,
+                'error' => '缺少必要数据表 paper_questions 或 papers',
+            ];
+        }
+
+        // 4 条硬约束参数(可通过命令行覆盖)
+        $calibrationMinAttempts = max(1, (int) ($options['calibration_min_attempts'] ?? 10));
+        $alpha = (float) ($options['alpha'] ?? 0.2);
+        $alpha = max(0.01, min(1.0, $alpha));
+        $maxStep = (float) ($options['max_step'] ?? 0.03);
+        $maxStep = max(0.001, min(0.2, $maxStep));
+        $halfLifeDays = max(1, (int) ($options['half_life_days'] ?? 30));
+
+        $perQuestion = $this->aggregatePerQuestion($minAttempts, $since, $studentId, $questionBankId);
+        $byPaperDifficulty = $this->aggregatePerQuestionByPaperDifficulty($since, $studentId, $questionBankId);
+        $bankDiffs = [];
+        $errorRates = [];
+        foreach ($perQuestion as $row) {
+            $d = self::normalizeDifficulty($row['bank_difficulty'] ?? null);
+            if ($d === null) {
+                continue;
+            }
+            $n = (int) $row['attempts'];
+            if ($n < 1) {
+                continue;
+            }
+            $acc = (float) $row['correct_count'] / $n;
+            $bankDiffs[] = $d;
+            $errorRates[] = 1.0 - $acc;
+        }
+
+        $bins = $this->binByDifficulty($perQuestion);
+        $pearson = $this->pearsonCorrelation($bankDiffs, $errorRates);
+
+        $paperLevelRows = $this->rowLevelPaperDifficultyVsOutcome($since, $studentId);
+
+        $mistakeByBankId = [];
+        if ($includeMistakes && Schema::hasTable('mistake_records')) {
+            $mistakeByBankId = $this->mistakeCountsByQuestionBankId($studentId);
+        }
+
+        $merged = [];
+        foreach ($perQuestion as $row) {
+            $bid = (int) $row['question_bank_id'];
+            $norm = self::normalizeDifficulty($row['bank_difficulty'] ?? null);
+            $emp = $row['attempts'] > 0
+                ? 1.0 - ((float) $row['correct_count'] / (int) $row['attempts'])
+                : null;
+            $gap = ($emp !== null && $norm !== null) ? round($emp - $norm, 4) : null;
+            $strata = $byPaperDifficulty[$bid] ?? [];
+            $calibration = $this->buildCalibrationRecommendation(
+                $norm,
+                $strata,
+                $calibrationMinAttempts,
+                $alpha,
+                $maxStep,
+                $halfLifeDays
+            );
+            $merged[] = array_merge($row, [
+                'wrong_count' => max(0, (int) $row['attempts'] - (int) $row['correct_count']),
+                'bank_difficulty_normalized' => $norm,
+                'empirical_error_rate' => $emp,
+                /** 实测错误率 − 题库难度(0–1):越大表示相对标定「更难做对」 */
+                'calibration_gap' => $gap,
+                'mistake_records_count' => $mistakeByBankId[$bid] ?? 0,
+                'paper_difficulty_breakdown' => $strata,
+                'calibration_weighted_error_rate' => $calibration['weighted_error_rate'],
+                'calibration_effective_attempts' => $calibration['effective_attempts'],
+                'calibration_recommendation' => $calibration['recommendation'],
+            ]);
+        }
+
+        return [
+            'ok' => true,
+            'meta' => [
+                'min_attempts' => $minAttempts,
+                'since' => $since?->toIso8601String(),
+                'student_id' => $studentId,
+                'question_bank_id' => $questionBankId,
+                'question_rows' => count($perQuestion),
+                'note' => '无独立「学生逐题自评难易」字段;mistake_records 为错题本行数。下列「每题一行」为 paper_questions 已判分聚合。',
+                'calibration_constraints' => [
+                    'stratified_by' => 'papers.difficulty_category',
+                    'min_attempts' => $calibrationMinAttempts,
+                    'alpha' => $alpha,
+                    'max_step' => $maxStep,
+                    'time_decay_half_life_days' => $halfLifeDays,
+                ],
+            ],
+            'summary' => [
+                'pearson_bank_difficulty_vs_empirical_error_rate' => $pearson,
+                'interpretation' => $this->interpretPearson($pearson),
+                'pearson_paper_difficulty_category_vs_incorrect' => $paperLevelRows['pearson_category_vs_incorrect'] ?? null,
+                'interpretation_paper_category' => $this->interpretPearson($paperLevelRows['pearson_category_vs_incorrect'] ?? null),
+            ],
+            'bins_by_bank_difficulty' => $bins,
+            'paper_difficulty_category_vs_incorrect_rate' => $paperLevelRows,
+            'per_question' => $merged,
+        ];
+    }
+
+    /**
+     * @return list<array<string, mixed>>
+     */
+    private function aggregatePerQuestion(int $minAttempts, ?Carbon $since, ?string $studentId, ?int $questionBankId): array
+    {
+        $q = DB::table('paper_questions as pq')
+            ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
+            ->leftJoin('questions as qu', 'qu.id', '=', 'pq.question_bank_id')
+            ->whereNotNull('pq.is_correct')
+            ->whereNotNull('pq.question_bank_id');
+
+        if ($studentId !== null) {
+            $q->where('p.student_id', $studentId);
+        }
+        if ($questionBankId !== null) {
+            $q->where('pq.question_bank_id', $questionBankId);
+        }
+
+        if ($since !== null) {
+            $q->where(function ($w) use ($since) {
+                $w->where('pq.updated_at', '>=', $since)
+                    ->orWhere('pq.graded_at', '>=', $since);
+            });
+        }
+
+        $rows = $q
+            ->groupBy('pq.question_bank_id')
+            ->havingRaw('COUNT(*) >= ?', [$minAttempts])
+            ->selectRaw('
+                pq.question_bank_id as question_bank_id,
+                COUNT(*) as attempts,
+                SUM(CASE WHEN pq.is_correct = 1 THEN 1 ELSE 0 END) as correct_count,
+                AVG(pq.difficulty) as avg_paper_question_difficulty,
+                MAX(qu.difficulty) as bank_difficulty,
+                MAX(qu.question_code) as question_code
+            ')
+            ->get();
+
+        return $rows->map(fn ($r) => [
+            'question_bank_id' => (int) $r->question_bank_id,
+            'question_code' => $r->question_code,
+            'attempts' => (int) $r->attempts,
+            'correct_count' => (int) $r->correct_count,
+            'accuracy' => $r->attempts > 0 ? round((int) $r->correct_count / (int) $r->attempts, 4) : null,
+            'avg_paper_question_difficulty' => $r->avg_paper_question_difficulty !== null ? (float) $r->avg_paper_question_difficulty : null,
+            'bank_difficulty' => $r->bank_difficulty !== null ? (float) $r->bank_difficulty : null,
+        ])->all();
+    }
+
+    /**
+     * 分层统计:每道题在不同 papers.difficulty_category 下的对错分布。
+     *
+     * @return array<int, list<array<string, mixed>>>
+     */
+    private function aggregatePerQuestionByPaperDifficulty(?Carbon $since, ?string $studentId, ?int $questionBankId): array
+    {
+        $q = DB::table('paper_questions as pq')
+            ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
+            ->whereNotNull('pq.is_correct')
+            ->whereNotNull('pq.question_bank_id');
+
+        if ($studentId !== null) {
+            $q->where('p.student_id', $studentId);
+        }
+        if ($questionBankId !== null) {
+            $q->where('pq.question_bank_id', $questionBankId);
+        }
+        if ($since !== null) {
+            $q->where(function ($w) use ($since) {
+                $w->where('pq.updated_at', '>=', $since)
+                    ->orWhere('pq.graded_at', '>=', $since);
+            });
+        }
+
+        $rows = $q->groupBy('pq.question_bank_id', 'p.difficulty_category')
+            ->selectRaw('
+                pq.question_bank_id as question_bank_id,
+                p.difficulty_category as difficulty_category,
+                COUNT(*) as attempts,
+                SUM(CASE WHEN pq.is_correct = 1 THEN 1 ELSE 0 END) as correct_count,
+                SUM(CASE WHEN pq.is_correct = 0 THEN 1 ELSE 0 END) as wrong_count,
+                MAX(COALESCE(pq.graded_at, pq.updated_at, pq.created_at)) as last_answered_at
+            ')
+            ->get();
+
+        $out = [];
+        foreach ($rows as $r) {
+            $bid = (int) $r->question_bank_id;
+            $attempts = (int) $r->attempts;
+            $wrong = (int) $r->wrong_count;
+            $out[$bid] ??= [];
+            $out[$bid][] = [
+                'difficulty_category' => $r->difficulty_category,
+                'difficulty_category_numeric' => self::parsePaperDifficultyCategory((string) ($r->difficulty_category ?? '')),
+                'attempts' => $attempts,
+                'correct_count' => (int) $r->correct_count,
+                'wrong_count' => $wrong,
+                'error_rate' => $attempts > 0 ? round($wrong / $attempts, 4) : null,
+                'last_answered_at' => $r->last_answered_at,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * 逐条作答:学案 difficulty_category(解析为 0–4 等级,再 /4 归一化)与是否做错(0/1)的 Pearson 相关。
+     *
+     * @return array{n_rows: int, n_rows_with_category: int, pearson_category_vs_incorrect: ?float, by_category: list<array<string, mixed>>}
+     */
+    private function rowLevelPaperDifficultyVsOutcome(?Carbon $since, ?string $studentId): array
+    {
+        $q = DB::table('paper_questions as pq')
+            ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
+            ->whereNotNull('pq.is_correct');
+
+        if ($studentId !== null) {
+            $q->where('p.student_id', $studentId);
+        }
+
+        if ($since !== null) {
+            $q->where(function ($w) use ($since) {
+                $w->where('pq.updated_at', '>=', $since)
+                    ->orWhere('pq.graded_at', '>=', $since);
+            });
+        }
+
+        $rows = $q->select(['pq.is_correct', 'p.difficulty_category'])->get();
+
+        $byCat = [];
+
+        foreach ($rows as $r) {
+            $cat = self::parsePaperDifficultyCategory($r->difficulty_category ?? null);
+            $key = $cat === null ? '_unknown' : (string) $cat;
+            if (! isset($byCat[$key])) {
+                $byCat[$key] = ['category' => $cat, 'n' => 0, 'incorrect' => 0];
+            }
+            $byCat[$key]['n']++;
+            $incorrect = ((int) $r->is_correct) === 0 ? 1 : 0;
+            $byCat[$key]['incorrect'] += $incorrect;
+        }
+
+        $outBy = [];
+        foreach ($byCat as $v) {
+            $n = $v['n'];
+            $outBy[] = [
+                'difficulty_category_numeric' => $v['category'],
+                'n' => $n,
+                'incorrect_rate' => $n > 0 ? round($v['incorrect'] / $n, 4) : null,
+            ];
+        }
+        usort($outBy, fn ($a, $b) => ($a['difficulty_category_numeric'] ?? -1) <=> ($b['difficulty_category_numeric'] ?? -1));
+
+        $xs = [];
+        $ys = [];
+        foreach ($rows as $r) {
+            $cat = self::parsePaperDifficultyCategory($r->difficulty_category ?? null);
+            if ($cat === null) {
+                continue;
+            }
+            $xs[] = $cat / 4.0;
+            $ys[] = ((int) $r->is_correct) === 0 ? 1.0 : 0.0;
+        }
+
+        return [
+            'n_rows' => $rows->count(),
+            'n_rows_with_category' => count($xs),
+            'pearson_category_vs_incorrect' => $this->pearsonCorrelation($xs, $ys),
+            'by_category' => $outBy,
+        ];
+    }
+
+    /**
+     * @return array<int, int> question_bank_id => mistake 行数(学生维度错题本条目)
+     */
+    private function mistakeCountsByQuestionBankId(?string $studentId): array
+    {
+        $mq = DB::table('mistake_records')
+            ->selectRaw('question_id, COUNT(*) as c')
+            ->groupBy('question_id');
+        if ($studentId !== null) {
+            $mq->where('student_id', $studentId);
+        }
+        $counts = $mq->pluck('c', 'question_id')->all();
+
+        $byBank = [];
+        foreach ($counts as $qid => $c) {
+            if (! is_numeric($qid)) {
+                continue;
+            }
+            $bankId = (int) $qid;
+            $byBank[$bankId] = ($byBank[$bankId] ?? 0) + (int) $c;
+        }
+
+        return $byBank;
+    }
+
+    /**
+     * @param  list<array<string, mixed>>  $perQuestion
+     * @return list<array<string, mixed>>
+     */
+    private function binByDifficulty(array $perQuestion): array
+    {
+        $edges = [0.0, 0.25, 0.5, 0.75, 1.0];
+        $bins = [];
+        for ($i = 0; $i < count($edges) - 1; $i++) {
+            $bins[] = [
+                'min' => $edges[$i],
+                'max' => $edges[$i + 1],
+                'n_questions' => 0,
+                'total_attempts' => 0,
+                'total_correct' => 0,
+                'mean_accuracy' => null,
+            ];
+        }
+
+        foreach ($perQuestion as $row) {
+            $d = self::normalizeDifficulty($row['bank_difficulty'] ?? null);
+            if ($d === null) {
+                continue;
+            }
+            // [0,0.25), [0.25,0.5), [0.5,0.75), [0.75,1.0]
+            $binIdx = (int) floor(min(0.999999, max(0.0, $d)) / 0.25);
+            if ($binIdx > 3) {
+                $binIdx = 3;
+            }
+            if ($binIdx < 0) {
+                $binIdx = 0;
+            }
+            $bins[$binIdx]['n_questions']++;
+            $bins[$binIdx]['total_attempts'] += (int) $row['attempts'];
+            $bins[$binIdx]['total_correct'] += (int) $row['correct_count'];
+        }
+
+        foreach ($bins as &$b) {
+            if ($b['total_attempts'] > 0) {
+                $b['mean_accuracy'] = round($b['total_correct'] / $b['total_attempts'], 4);
+            }
+        }
+        unset($b);
+
+        return $bins;
+    }
+
+    private function interpretPearson(?float $r): string
+    {
+        if ($r === null) {
+            return '样本不足或难度无变异,无法计算相关系数。';
+        }
+        if ($r > 0.15) {
+            return '题库难度与实测错误率呈正相关:标定越高的题,学生越容易错,方向符合预期。';
+        }
+        if ($r < -0.15) {
+            return '出现负相关:标定「难」的题反而正确率更高,建议检查标定、题型或样本偏差。';
+        }
+
+        return '相关较弱:标定难度与实测区分度不明显,可能样本量、标定噪声或题目同质性导致。';
+    }
+
+    /**
+     * 将 papers.difficulty_category 解析为 0–4 的等级,再归一化到 0–1(便于与 0–1 题库难度对照)。
+     */
+    public static function parsePaperDifficultyCategory(?string $raw): ?float
+    {
+        if ($raw === null) {
+            return null;
+        }
+        $s = strtolower(trim((string) $raw));
+        if ($s === '') {
+            return null;
+        }
+        if (is_numeric($s)) {
+            $n = (int) $s;
+
+            return (float) max(0, min(4, $n));
+        }
+
+        // 与业务侧 0–4 档一致:0 基础 / 1 筑基 / 2 提分 / 3 培优 / 4 竞赛(与 MasteryCalculator 区间命名对齐)
+        $level = match ($s) {
+            '0', '零基础', '0基础', '基础', '0级' => 0.0,
+            '1', '筑基' => 1.0,
+            '2', '进阶', '中等', '提分' => 2.0,
+            '3', '培优' => 3.0,
+            '4', '竞赛' => 4.0,
+            default => null,
+        };
+
+        return $level;
+    }
+
+    public static function normalizeDifficulty(?float $d): ?float
+    {
+        if ($d === null) {
+            return null;
+        }
+        $f = (float) $d;
+
+        return $f > 1.0 ? $f / 5.0 : $f;
+    }
+
+    /**
+     * @param  list<float>  $x
+     * @param  list<float>  $y
+     */
+    private function pearsonCorrelation(array $x, array $y): ?float
+    {
+        $n = count($x);
+        if ($n < 3 || count($y) !== $n) {
+            return null;
+        }
+        $mx = array_sum($x) / $n;
+        $my = array_sum($y) / $n;
+        $num = 0.0;
+        $dx = 0.0;
+        $dy = 0.0;
+        for ($i = 0; $i < $n; $i++) {
+            $vx = $x[$i] - $mx;
+            $vy = $y[$i] - $my;
+            $num += $vx * $vy;
+            $dx += $vx * $vx;
+            $dy += $vy * $vy;
+        }
+        $den = sqrt($dx * $dy);
+
+        return $den > 1e-12 ? round($num / $den, 4) : null;
+    }
+
+    /**
+     * 在四条硬约束下给出每题的动态难度建议。
+     *
+     * 约束:
+     * 1) 分层:先按 papers.difficulty_category 切分;
+     * 2) 样本门槛:有效样本不足则不动;
+     * 3) 平滑 + 限幅:delta = clip(alpha * gap, -maxStep, maxStep);
+     * 4) 时间衰减:分层样本按最近作答时间加权(半衰期 halfLifeDays)。
+     *
+     * @param  list<array<string, mixed>>  $strata
+     * @return array{
+     *   weighted_error_rate:?float,
+     *   effective_attempts:float,
+     *   recommendation:array{
+     *     action:string,
+     *     reason:string,
+     *     gap:?float,
+     *     delta:?float,
+     *     suggested_difficulty:?float
+     *   }
+     * }
+     */
+    private function buildCalibrationRecommendation(
+        ?float $bankDifficultyNormalized,
+        array $strata,
+        int $minAttempts,
+        float $alpha,
+        float $maxStep,
+        int $halfLifeDays
+    ): array {
+        if ($bankDifficultyNormalized === null) {
+            return [
+                'weighted_error_rate' => null,
+                'effective_attempts' => 0.0,
+                'recommendation' => [
+                    'action' => 'hold',
+                    'reason' => '题库难度为空,无法计算建议。',
+                    'gap' => null,
+                    'delta' => null,
+                    'suggested_difficulty' => null,
+                ],
+            ];
+        }
+
+        $now = Carbon::now();
+        $weightedAttempts = 0.0;
+        $weightedWrong = 0.0;
+
+        foreach ($strata as $s) {
+            $attempts = (int) ($s['attempts'] ?? 0);
+            $wrong = (int) ($s['wrong_count'] ?? 0);
+            if ($attempts <= 0) {
+                continue;
+            }
+            $lastAtRaw = $s['last_answered_at'] ?? null;
+            $days = 0.0;
+            if ($lastAtRaw) {
+                try {
+                    $lastAt = Carbon::parse((string) $lastAtRaw);
+                    $days = max(0.0, (float) $lastAt->diffInDays($now));
+                } catch (\Throwable) {
+                    $days = 0.0;
+                }
+            }
+            $w = pow(0.5, $days / $halfLifeDays);
+            $weightedAttempts += $attempts * $w;
+            $weightedWrong += $wrong * $w;
+        }
+
+        if ($weightedAttempts <= 0.0) {
+            return [
+                'weighted_error_rate' => null,
+                'effective_attempts' => 0.0,
+                'recommendation' => [
+                    'action' => 'hold',
+                    'reason' => '无有效样本,保持不变。',
+                    'gap' => null,
+                    'delta' => null,
+                    'suggested_difficulty' => round($bankDifficultyNormalized, 4),
+                ],
+            ];
+        }
+
+        $weightedErrorRate = $weightedWrong / $weightedAttempts;
+        $gap = $weightedErrorRate - $bankDifficultyNormalized;
+
+        if ($weightedAttempts < $minAttempts) {
+            return [
+                'weighted_error_rate' => round($weightedErrorRate, 4),
+                'effective_attempts' => round($weightedAttempts, 2),
+                'recommendation' => [
+                    'action' => 'hold',
+                    'reason' => '有效样本不足门槛 '.$minAttempts.',仅观测不调整。',
+                    'gap' => round($gap, 4),
+                    'delta' => 0.0,
+                    'suggested_difficulty' => round($bankDifficultyNormalized, 4),
+                ],
+            ];
+        }
+
+        $delta = max(-$maxStep, min($maxStep, $alpha * $gap));
+        $suggested = max(0.0, min(1.0, $bankDifficultyNormalized + $delta));
+        $eps = 1e-6;
+        $action = $delta > $eps ? 'increase' : ($delta < -$eps ? 'decrease' : 'hold');
+        $reason = match ($action) {
+            'increase' => '实测(分层+时衰)错误率高于标定,建议小步上调。',
+            'decrease' => '实测(分层+时衰)错误率低于标定,建议小步下调。',
+            default => 'gap 接近 0,建议保持不变。',
+        };
+
+        return [
+            'weighted_error_rate' => round($weightedErrorRate, 4),
+            'effective_attempts' => round($weightedAttempts, 2),
+            'recommendation' => [
+                'action' => $action,
+                'reason' => $reason,
+                'gap' => round($gap, 4),
+                'delta' => round($delta, 4),
+                'suggested_difficulty' => round($suggested, 4),
+            ],
+        ];
+    }
+}

+ 1096 - 0
app/Services/Analytics/QuestionDifficultyCalibrationService.php

@@ -0,0 +1,1096 @@
+<?php
+
+namespace App\Services\Analytics;
+
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 题目动态难度校准服务(分层基线残差 + 贝叶斯收缩 + 时间衰减)
+ *
+ * 目标:
+ * 1) 判卷后实时吸收对错结果;
+ * 2) 产出可直接用于组卷的校准难度(0~1);
+ * 3) 通过先验约束与限幅,避免短期噪声导致难度抖动。
+ */
+class QuestionDifficultyCalibrationService
+{
+    private const TABLE = 'question_difficulty_calibrations';
+    private const ALGO = 'stratified_residual_eb_v2';
+    private const HALF_LIFE_DAYS = 45;
+    private const BETA_PRIOR_A = 2.0;
+    private const BETA_PRIOR_B = 2.0;
+    private const SHRINKAGE_M0_MIN = 8.0;
+    private const SHRINKAGE_M0_MAX = 24.0;
+    private const RESIDUAL_GAIN_MIN = 1.0;
+    private const RESIDUAL_GAIN_MAX = 2.2;
+    private const RESIDUAL_SCALE_DENOM_MIN = 0.08;
+    private const RESIDUAL_SCALE_DENOM_MAX = 0.20;
+    private const RECENT_EVENTS_LIMIT = 30;
+    private const MIN_DIFF = 0.01;
+    private const MAX_DIFF = 0.99;
+
+    private ?bool $tableReady = null;
+    /** @var array<string, array{by_cat:array<string,float>,fallback:float,all_by_cat:array<string,float},> */
+    private array $baselineCache = [];
+
+    /**
+     * 按一张试卷内已判分题目触发重估。
+     *
+     * @return int 参与更新的题目数
+     */
+    public function recalibrateByPaperId(string $paperId): int
+    {
+        $paperId = trim($paperId);
+        if ($paperId === '' || ! $this->isReady()) {
+            return 0;
+        }
+
+        $questionIds = DB::table('paper_questions')
+            ->where('paper_id', $paperId)
+            ->whereNotNull('question_bank_id')
+            ->whereNotNull('is_correct')
+            ->pluck('question_bank_id')
+            ->map(fn ($id) => (int) $id)
+            ->filter(fn ($id) => $id > 0)
+            ->unique()
+            ->values()
+            ->all();
+
+        return $this->recalibrateQuestionIds($questionIds);
+    }
+
+    /**
+     * 在线逐题更新(无批量重算):仅更新本次判卷触达的题目。
+     *
+     * @param  list<array<string,mixed>>  $questions
+     */
+    public function updateOnlineFromPaper(string $paperId, array $questions): int
+    {
+        $paperId = trim($paperId);
+        if ($paperId === '' || $questions === [] || ! $this->isReady()) {
+            return 0;
+        }
+
+        $paper = DB::table('papers')->where('paper_id', $paperId)->first(['difficulty_category']);
+        $paperDifficultyCategory = (string) ($paper->difficulty_category ?? 'unknown');
+
+        $qidToOutcome = [];
+        foreach ($questions as $question) {
+            $qid = (int) ($question['question_id'] ?? $question['question_bank_id'] ?? 0);
+            if ($qid <= 0) {
+                continue;
+            }
+
+            $isCorrectArray = $question['is_correct'] ?? [];
+            if (! is_array($isCorrectArray)) {
+                $isCorrectArray = [$isCorrectArray ? 1 : 0];
+            }
+            $totalSteps = count($isCorrectArray);
+            if ($totalSteps <= 0) {
+                continue;
+            }
+            $correctSteps = array_sum(array_map(fn ($v) => (int) $v === 1 ? 1 : 0, $isCorrectArray));
+            $correctRatio = $correctSteps / max(1, $totalSteps);
+            $outcomeError = 1.0 - $correctRatio;
+            $qidToOutcome[$qid] = [
+                'outcome_error' => $this->clamp((float) $outcomeError, 0.0, 1.0),
+                'is_fully_correct' => $correctSteps === $totalSteps ? 1 : 0,
+            ];
+        }
+        if ($qidToOutcome === []) {
+            return 0;
+        }
+
+        $questionIds = array_keys($qidToOutcome);
+        $questionTypeRows = DB::table('paper_questions')
+            ->where('paper_id', $paperId)
+            ->where(function ($q) use ($questionIds) {
+                $q->whereIn('question_bank_id', $questionIds)
+                    ->orWhereIn('question_id', $questionIds);
+            })
+            ->select(['question_bank_id', 'question_id', 'question_type'])
+            ->get();
+        $questionTypeByQid = [];
+        $canonicalQidByInput = [];
+        foreach ($questionTypeRows as $row) {
+            $qt = trim((string) ($row->question_type ?? '')) !== '' ? (string) $row->question_type : 'unknown';
+            $bankId = (int) ($row->question_bank_id ?? 0);
+            $questionId = (int) ($row->question_id ?? 0);
+            if ($bankId > 0 && ! isset($questionTypeByQid[$bankId])) {
+                $questionTypeByQid[$bankId] = $qt;
+                $canonicalQidByInput[$bankId] = $bankId;
+            }
+            if ($questionId > 0 && ! isset($questionTypeByQid[$questionId])) {
+                $questionTypeByQid[$questionId] = $qt;
+            }
+            if ($questionId > 0 && $bankId > 0) {
+                $canonicalQidByInput[$questionId] = $bankId;
+            }
+        }
+        $baseDifficultyByQid = DB::table('questions')
+            ->whereIn('id', $questionIds)
+            ->pluck('difficulty', 'id')
+            ->all();
+        $existingLookupIds = array_values(array_unique(array_merge(
+            $questionIds,
+            array_values($canonicalQidByInput)
+        )));
+        $existingByQid = DB::table(self::TABLE)
+            ->whereIn('question_bank_id', $existingLookupIds)
+            ->get()
+            ->keyBy('question_bank_id');
+
+        $types = array_values(array_unique(array_values($questionTypeByQid)));
+        $baselines = $this->buildGlobalBaselines($types);
+        $healthScaleByType = [];
+
+        $now = now();
+        $upserts = [];
+        foreach ($qidToOutcome as $qid => $outcome) {
+            $outcomeError = (float) ($outcome['outcome_error'] ?? 1.0);
+            $isFullyCorrect = (int) ($outcome['is_fully_correct'] ?? 0) === 1 ? 1 : 0;
+            $canonicalQid = (int) ($canonicalQidByInput[$qid] ?? $qid);
+            $existing = $existingByQid->get((string) $canonicalQid);
+            if ($existing === null) {
+                $existing = $existingByQid->get($canonicalQid);
+            }
+
+            $originalDifficulty = $existing !== null
+                ? (float) ($existing->original_difficulty ?? 0.5)
+                : ($this->normalizeDifficultyValue($baseDifficultyByQid[$qid] ?? null) ?? 0.5);
+            $originalDifficulty = $this->clamp($originalDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+            $prevDifficulty = $existing !== null
+                ? (float) ($existing->calibrated_difficulty ?? $originalDifficulty)
+                : $originalDifficulty;
+            $prevDifficulty = $this->clamp($prevDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+            $prevWeightedAttempts = $existing !== null ? (float) ($existing->weighted_attempts ?? 0.0) : 0.0;
+            $prevWeightedWrong = $existing !== null ? (float) ($existing->weighted_wrong ?? 0.0) : 0.0;
+            $lastAtRaw = $existing !== null ? ($existing->last_graded_at ?? null) : null;
+            $existingMeta = [];
+            if ($existing !== null && ! empty($existing->algorithm_meta)) {
+                $existingMeta = json_decode((string) $existing->algorithm_meta, true) ?: [];
+            }
+
+            $questionType = $questionTypeByQid[$canonicalQid] ?? ($questionTypeByQid[$qid] ?? 'unknown');
+            $baselineErr = $this->resolveBaselineErrorRate($questionType, $paperDifficultyCategory, $baselines);
+            if (! isset($healthScaleByType[$questionType])) {
+                $healthScaleByType[$questionType] = $this->getHealthScaleForType($questionType);
+            }
+            $healthScale = (float) $healthScaleByType[$questionType];
+
+            $estimate = $this->estimateOnlineBySingleOutcome(
+                $originalDifficulty,
+                $prevDifficulty,
+                $prevWeightedAttempts,
+                $prevWeightedWrong,
+                $outcomeError,
+                $baselineErr,
+                $lastAtRaw,
+                $healthScale
+            );
+
+            $event = $this->buildUpdateEvent(
+                $outcomeError,
+                $prevDifficulty,
+                (float) $estimate['calibrated_difficulty'],
+                (float) ($estimate['meta']['expected_error_rate'] ?? $baselineErr),
+                (float) ($estimate['meta']['observed_error_rate'] ?? ($estimate['weighted_error_rate'] ?? 0.5)),
+                (float) ($estimate['meta']['residual'] ?? 0.0),
+                $now
+            );
+
+            $meta = array_merge($existingMeta, $estimate['meta'], [
+                'mode' => 'online_single_outcome',
+                'paper_id' => $paperId,
+                'paper_difficulty_category' => $paperDifficultyCategory,
+                'question_type' => $questionType,
+                'baseline_error_rate' => round($baselineErr, 4),
+                'health_scale' => round($healthScale, 4),
+            ]);
+            $meta = $this->appendRecentEvent($meta, $event);
+
+            $prevAttempts = $existing !== null ? (int) ($existing->attempts ?? 0) : 0;
+            $prevCorrectCount = $existing !== null ? (int) ($existing->correct_count ?? 0) : 0;
+            $prevWrongCount = $existing !== null ? (int) ($existing->wrong_count ?? 0) : 0;
+            $attempts = $prevAttempts + 1;
+            $correctCount = $prevCorrectCount + ($isFullyCorrect === 1 ? 1 : 0);
+            // wrong_count 与历史 is_correct 口径对齐:仅“全错”计入 wrong_count。
+            $wrongCount = $prevWrongCount + ($outcomeError >= 0.9999 ? 1 : 0);
+
+            $upserts[] = [
+                'question_bank_id' => $canonicalQid,
+                'original_difficulty' => round($originalDifficulty, 4),
+                'calibrated_difficulty' => round($estimate['calibrated_difficulty'], 4),
+                'difficulty_delta' => round($estimate['calibrated_difficulty'] - $originalDifficulty, 4),
+                'attempts' => $attempts,
+                'correct_count' => $correctCount,
+                'wrong_count' => $wrongCount,
+                'weighted_attempts' => round($estimate['weighted_attempts'], 4),
+                'weighted_wrong' => round($estimate['weighted_wrong'], 4),
+                'weighted_error_rate' => round($estimate['weighted_error_rate'], 4),
+                'last_graded_at' => $now->toDateTimeString(),
+                'algorithm' => self::ALGO.'_online',
+                'algorithm_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
+                'updated_at' => $now,
+                'created_at' => $now,
+            ];
+        }
+
+        if ($upserts === []) {
+            return 0;
+        }
+
+        DB::table(self::TABLE)->upsert(
+            $upserts,
+            ['question_bank_id'],
+            [
+                'original_difficulty',
+                'calibrated_difficulty',
+                'difficulty_delta',
+                'attempts',
+                'correct_count',
+                'wrong_count',
+                'weighted_attempts',
+                'weighted_wrong',
+                'weighted_error_rate',
+                'last_graded_at',
+                'algorithm',
+                'algorithm_meta',
+                'updated_at',
+            ]
+        );
+
+        Log::info('QuestionDifficultyCalibrationService: 在线逐题更新完成', [
+            'paper_id' => $paperId,
+            'updated_question_count' => count($upserts),
+        ]);
+
+        return count($upserts);
+    }
+
+    /**
+     * @param  array<int, int|string>  $questionIds
+     * @return int 参与更新的题目数
+     */
+    public function recalibrateQuestionIds(array $questionIds): int
+    {
+        if (! $this->isReady()) {
+            return 0;
+        }
+
+        $questionIds = collect($questionIds)
+            ->map(fn ($id) => (int) $id)
+            ->filter(fn ($id) => $id > 0)
+            ->unique()
+            ->values()
+            ->all();
+        if ($questionIds === []) {
+            return 0;
+        }
+
+        $baseDifficultyById = DB::table('questions')
+            ->whereIn('id', $questionIds)
+            ->pluck('difficulty', 'id')
+            ->all();
+
+        $rows = DB::table('paper_questions as pq')
+            ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
+            ->whereIn('pq.question_bank_id', $questionIds)
+            ->whereNotNull('pq.is_correct')
+            ->select([
+                'pq.question_bank_id',
+                'pq.question_type',
+                'pq.is_correct',
+                'pq.graded_at',
+                'pq.updated_at',
+                'pq.created_at',
+                'p.difficulty_category',
+            ])
+            ->orderBy('pq.question_bank_id')
+            ->get();
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $qid = (int) ($row->question_bank_id ?? 0);
+            if ($qid <= 0) {
+                continue;
+            }
+            $grouped[$qid] ??= [];
+            $grouped[$qid][] = [
+                'question_type' => (string) ($row->question_type ?? ''),
+                'is_correct' => (int) ($row->is_correct ?? 0) === 1 ? 1 : 0,
+                'difficulty_category' => $row->difficulty_category ?? null,
+                'graded_at' => $row->graded_at ?? null,
+                'updated_at' => $row->updated_at ?? null,
+                'created_at' => $row->created_at ?? null,
+            ];
+        }
+
+        $questionTypeById = [];
+        foreach ($grouped as $qid => $attempts) {
+            $questionTypeById[$qid] = $this->resolveQuestionType($attempts);
+        }
+        $baselines = $this->buildGlobalBaselines(array_values($questionTypeById));
+
+        $upserts = [];
+        $now = now();
+        foreach ($questionIds as $qid) {
+            $attempts = $grouped[$qid] ?? [];
+            if ($attempts === []) {
+                continue;
+            }
+
+            $originalDifficulty = $this->normalizeDifficultyValue($baseDifficultyById[$qid] ?? null) ?? 0.5;
+            $questionType = $questionTypeById[$qid] ?? 'unknown';
+            $estimate = $this->estimateByStratifiedResidual(
+                $attempts,
+                $originalDifficulty,
+                $questionType,
+                $baselines
+            );
+
+            $upserts[] = [
+                'question_bank_id' => $qid,
+                'original_difficulty' => round($originalDifficulty, 4),
+                'calibrated_difficulty' => round($estimate['calibrated_difficulty'], 4),
+                'difficulty_delta' => round($estimate['calibrated_difficulty'] - $originalDifficulty, 4),
+                'attempts' => $estimate['attempts'],
+                'correct_count' => $estimate['correct_count'],
+                'wrong_count' => $estimate['wrong_count'],
+                'weighted_attempts' => round($estimate['weighted_attempts'], 4),
+                'weighted_wrong' => round($estimate['weighted_wrong'], 4),
+                'weighted_error_rate' => $estimate['weighted_error_rate'] === null
+                    ? null
+                    : round($estimate['weighted_error_rate'], 4),
+                'last_graded_at' => $estimate['last_graded_at'],
+                'algorithm' => self::ALGO,
+                'algorithm_meta' => json_encode($estimate['meta'], JSON_UNESCAPED_UNICODE),
+                'updated_at' => $now,
+                'created_at' => $now,
+            ];
+        }
+
+        if ($upserts === []) {
+            return 0;
+        }
+
+        DB::table(self::TABLE)->upsert(
+            $upserts,
+            ['question_bank_id'],
+            [
+                'original_difficulty',
+                'calibrated_difficulty',
+                'difficulty_delta',
+                'attempts',
+                'correct_count',
+                'wrong_count',
+                'weighted_attempts',
+                'weighted_wrong',
+                'weighted_error_rate',
+                'last_graded_at',
+                'algorithm',
+                'algorithm_meta',
+                'updated_at',
+            ]
+        );
+
+        Log::info('QuestionDifficultyCalibrationService: 题目难度已重估入库', [
+            'question_count' => count($upserts),
+            'algorithm' => self::ALGO,
+        ]);
+
+        return count($upserts);
+    }
+
+    private function resolveQuestionType(array $attempts): string
+    {
+        foreach ($attempts as $attempt) {
+            $type = trim((string) ($attempt['question_type'] ?? ''));
+            if ($type !== '') {
+                return $type;
+            }
+        }
+
+        return 'unknown';
+    }
+
+    /**
+     * @param  array<int, string>  $questionTypes
+     * @return array<string, mixed>
+     */
+    private function buildGlobalBaselines(array $questionTypes): array
+    {
+        $cacheKey = implode('|', $questionTypes);
+        if (isset($this->baselineCache[$cacheKey])) {
+            return $this->baselineCache[$cacheKey];
+        }
+
+        $questionTypes = array_values(array_unique(array_filter(array_map(
+            fn ($t) => trim((string) $t),
+            $questionTypes
+        ))));
+        sort($questionTypes);
+
+        $cacheKeyPersistent = 'difficulty_baselines_v1:'.md5(implode('|', $questionTypes));
+        $result = Cache::remember($cacheKeyPersistent, now()->addMinutes(10), function () use ($questionTypes) {
+            $baseQuery = DB::table('paper_questions as pq')
+                ->join('papers as p', 'p.paper_id', '=', 'pq.paper_id')
+                ->whereNotNull('pq.is_correct');
+
+            $rows = (clone $baseQuery)
+                ->when($questionTypes !== [], function ($q) use ($questionTypes) {
+                    $q->whereIn('pq.question_type', $questionTypes);
+                })
+                ->selectRaw('
+                    COALESCE(NULLIF(pq.question_type, ""), "unknown") as question_type,
+                    COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown") as difficulty_category,
+                    COUNT(*) as n,
+                    SUM(CASE WHEN pq.is_correct = 0 THEN 1 ELSE 0 END) as wrong
+                ')
+                ->groupBy(DB::raw('COALESCE(NULLIF(pq.question_type, ""), "unknown")'))
+                ->groupBy(DB::raw('COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown")'))
+                ->get();
+
+            $allRows = (clone $baseQuery)
+                ->selectRaw('
+                    COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown") as difficulty_category,
+                    COUNT(*) as n,
+                    SUM(CASE WHEN pq.is_correct = 0 THEN 1 ELSE 0 END) as wrong
+                ')
+                ->groupBy(DB::raw('COALESCE(NULLIF(CAST(p.difficulty_category as char), ""), "unknown")'))
+                ->get();
+
+            $result = [
+                'type' => [],
+                'all' => [
+                    'by_cat' => [],
+                    'fallback' => 0.5,
+                ],
+            ];
+
+            foreach ($rows as $row) {
+                $type = (string) ($row->question_type ?? 'unknown');
+                $cat = (string) ($row->difficulty_category ?? 'unknown');
+                $n = (int) ($row->n ?? 0);
+                $wrong = (int) ($row->wrong ?? 0);
+                $result['type'][$type]['by_cat'][$cat] = $this->smoothedRate($wrong, $n);
+                $result['type'][$type]['n_total'] = (int) (($result['type'][$type]['n_total'] ?? 0) + $n);
+                $result['type'][$type]['wrong_total'] = (int) (($result['type'][$type]['wrong_total'] ?? 0) + $wrong);
+            }
+
+            foreach ($result['type'] as $type => $v) {
+                $n = (int) ($v['n_total'] ?? 0);
+                $wrong = (int) ($v['wrong_total'] ?? 0);
+                $result['type'][$type]['fallback'] = $this->smoothedRate($wrong, $n);
+                $result['type'][$type]['by_cat'] = $this->enforceMonotonicCategoryRates(
+                    $result['type'][$type]['by_cat'] ?? []
+                );
+            }
+
+            $allN = 0;
+            $allWrong = 0;
+            foreach ($allRows as $row) {
+                $cat = (string) ($row->difficulty_category ?? 'unknown');
+                $n = (int) ($row->n ?? 0);
+                $wrong = (int) ($row->wrong ?? 0);
+                $result['all']['by_cat'][$cat] = $this->smoothedRate($wrong, $n);
+                $allN += $n;
+                $allWrong += $wrong;
+            }
+            $result['all']['by_cat'] = $this->enforceMonotonicCategoryRates($result['all']['by_cat']);
+            $result['all']['fallback'] = $this->smoothedRate($allWrong, $allN);
+
+            return $result;
+        });
+
+        $this->baselineCache[$cacheKey] = $result;
+
+        return $result;
+    }
+
+    private function resolveBaselineErrorRate(string $questionType, string $difficultyCategory, array $baselines): float
+    {
+        $type = trim($questionType) !== '' ? trim($questionType) : 'unknown';
+        $cat = trim($difficultyCategory) !== '' ? trim($difficultyCategory) : 'unknown';
+
+        $typeByCat = $baselines['type'][$type]['by_cat'] ?? [];
+        if (array_key_exists($cat, $typeByCat)) {
+            return (float) $typeByCat[$cat];
+        }
+        if (isset($baselines['type'][$type]['fallback'])) {
+            return (float) $baselines['type'][$type]['fallback'];
+        }
+        if (isset($baselines['all']['by_cat'][$cat])) {
+            return (float) $baselines['all']['by_cat'][$cat];
+        }
+
+        return (float) ($baselines['all']['fallback'] ?? 0.5);
+    }
+
+    private function smoothedRate(int $wrong, int $n): float
+    {
+        return ($wrong + self::BETA_PRIOR_A) / max(1e-6, $n + self::BETA_PRIOR_A + self::BETA_PRIOR_B);
+    }
+
+    /**
+     * 约束 difficulty_category 的基线错误率单调递增(0<=1<=2<=...),
+     * 保留 unknown 等非数字类别原值。
+     *
+     * @param  array<string,float>  $ratesByCategory
+     * @return array<string,float>
+     */
+    private function enforceMonotonicCategoryRates(array $ratesByCategory): array
+    {
+        if ($ratesByCategory === []) {
+            return $ratesByCategory;
+        }
+
+        $numeric = [];
+        foreach ($ratesByCategory as $cat => $rate) {
+            if (preg_match('/^\\d+$/', (string) $cat) === 1) {
+                $numeric[(int) $cat] = (float) $rate;
+            }
+        }
+        if ($numeric === []) {
+            return $ratesByCategory;
+        }
+        ksort($numeric);
+
+        $keys = array_keys($numeric);
+        $vals = array_values($numeric);
+        $adj = $this->isotonicIncreasing($vals);
+
+        foreach ($keys as $i => $cat) {
+            $ratesByCategory[(string) $cat] = $adj[$i];
+        }
+
+        return $ratesByCategory;
+    }
+
+    /**
+     * @param  array<int,float>  $values
+     * @return array<int,float>
+     */
+    private function isotonicIncreasing(array $values): array
+    {
+        $blocks = [];
+        foreach ($values as $v) {
+            $blocks[] = ['sum' => (float) $v, 'weight' => 1.0, 'count' => 1];
+            while (count($blocks) >= 2) {
+                $k = count($blocks);
+                $a = $blocks[$k - 2];
+                $b = $blocks[$k - 1];
+                $avgA = $a['sum'] / $a['weight'];
+                $avgB = $b['sum'] / $b['weight'];
+                if ($avgA <= $avgB) {
+                    break;
+                }
+                $blocks[$k - 2] = [
+                    'sum' => $a['sum'] + $b['sum'],
+                    'weight' => $a['weight'] + $b['weight'],
+                    'count' => $a['count'] + $b['count'],
+                ];
+                array_pop($blocks);
+            }
+        }
+
+        $out = [];
+        foreach ($blocks as $b) {
+            $avg = (float) ($b['sum'] / max(1e-6, $b['weight']));
+            for ($i = 0; $i < (int) $b['count']; $i++) {
+                $out[] = $this->clamp($avg, self::MIN_DIFF, self::MAX_DIFF);
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * 单次判卷结果的在线更新。
+     *
+     * @return array{weighted_attempts:float,weighted_wrong:float,weighted_error_rate:float,calibrated_difficulty:float,meta:array<string,mixed>}
+     */
+    private function estimateOnlineBySingleOutcome(
+        float $originalDifficulty,
+        float $prevDifficulty,
+        float $prevWeightedAttempts,
+        float $prevWeightedWrong,
+        float $outcomeError,
+        float $baselineErr,
+        mixed $lastGradedAtRaw,
+        float $healthScale
+    ): array {
+        $now = Carbon::now();
+
+        $days = 0.0;
+        if ($lastGradedAtRaw !== null && (string) $lastGradedAtRaw !== '') {
+            try {
+                $lastAt = Carbon::parse((string) $lastGradedAtRaw);
+                $days = max(0.0, (float) $lastAt->diffInDays($now));
+            } catch (\Throwable) {
+                $days = 0.0;
+            }
+        }
+        $decay = pow(0.5, $days / self::HALF_LIFE_DAYS);
+        $outcomeError = $this->clamp($outcomeError, 0.0, 1.0);
+        $wN = max(0.0, $prevWeightedAttempts) * $decay + 1.0;
+        $wWrong = max(0.0, $prevWeightedWrong) * $decay + $outcomeError;
+        $obsErr = $wN > 0.0 ? ($wWrong / $wN) : 0.5;
+        $priorConfidence = min(1.0, max(0.0, $prevWeightedAttempts / 25.0));
+        $expectedErr = (1.0 - $priorConfidence) * $baselineErr + $priorConfidence * $prevDifficulty;
+        $residual = $this->clamp($obsErr - $expectedErr, -0.45, 0.45);
+
+        $adaptive = $this->buildAdaptivePolicy($wN, $obsErr, $expectedErr, $residual);
+        $residualGain = (float) $adaptive['residual_gain'] * $healthScale;
+        $residualScaleDenom = (float) $adaptive['residual_scale_denom'];
+        $shrinkageM0 = (float) $adaptive['shrinkage_m0'];
+        $confidence = (float) ($adaptive['confidence'] ?? 0.0);
+
+        // 在线模式下不做分段门控,始终可更新,但样本少时步长自动更小。
+        $maxStep = 0.30 * (0.35 + 0.65 * $confidence) * $healthScale;
+        $residualScale = min(1.0, abs($residual) / max(1e-6, $residualScaleDenom));
+        $effectiveStep = $maxStep * $residualScale;
+
+        $targetDifficulty = $this->clamp(
+            $prevDifficulty + $residualGain * $residual,
+            self::MIN_DIFF,
+            self::MAX_DIFF
+        );
+        $candidateDifficulty = $prevDifficulty + $this->clamp(
+            $targetDifficulty - $prevDifficulty,
+            -$effectiveStep,
+            $effectiveStep
+        );
+        $candidateDifficulty = $this->clamp($candidateDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+        $calibratedDifficulty = ($shrinkageM0 * $prevDifficulty + $wN * $candidateDifficulty) / ($shrinkageM0 + $wN);
+        $calibratedDifficulty = $this->clamp($calibratedDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+        return [
+            'weighted_attempts' => $wN,
+            'weighted_wrong' => $wWrong,
+            'weighted_error_rate' => $obsErr,
+            'calibrated_difficulty' => $calibratedDifficulty,
+            'meta' => [
+                'decay_days' => round($days, 4),
+                'decay_factor' => round($decay, 6),
+                'prev_difficulty' => round($prevDifficulty, 4),
+                'original_difficulty' => round($originalDifficulty, 4),
+                'observed_error_rate' => round($obsErr, 4),
+                'expected_error_rate' => round($expectedErr, 4),
+                'residual' => round($residual, 4),
+                'health_scale_applied' => round($healthScale, 4),
+                'max_step' => round($maxStep, 4),
+                'effective_step' => round($effectiveStep, 4),
+                'target_difficulty' => round($targetDifficulty, 4),
+                'candidate_difficulty' => round($candidateDifficulty, 4),
+                'adaptive' => $adaptive,
+            ],
+        ];
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $attempts
+     * @param  array<string, mixed>  $baselines
+     * @return array<string, mixed>
+     */
+    private function estimateByStratifiedResidual(
+        array $attempts,
+        float $originalDifficulty,
+        string $questionType,
+        array $baselines
+    ): array {
+        $now = Carbon::now();
+        $originalDifficulty = $this->clamp($originalDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+        $weightedAttempts = 0.0;
+        $weightedWrong = 0.0;
+        $weightedExpectedWrong = 0.0;
+        $correctCount = 0;
+        $wrongCount = 0;
+        $lastAt = null;
+        $byCategory = [];
+
+        foreach ($attempts as $attempt) {
+            $isCorrect = (int) ($attempt['is_correct'] ?? 0) === 1 ? 1 : 0;
+            $incorrect = 1 - $isCorrect;
+            if ($isCorrect === 1) {
+                $correctCount++;
+            } else {
+                $wrongCount++;
+            }
+
+            $difficultyCategory = (string) ($attempt['difficulty_category'] ?? 'unknown');
+            $baselineErr = $this->resolveBaselineErrorRate($questionType, $difficultyCategory, $baselines);
+
+            $answeredAt = $attempt['graded_at'] ?? $attempt['updated_at'] ?? $attempt['created_at'] ?? null;
+            $days = 0.0;
+            if ($answeredAt !== null && $answeredAt !== '') {
+                try {
+                    $at = Carbon::parse((string) $answeredAt);
+                    $days = max(0.0, (float) $at->diffInDays($now));
+                    if ($lastAt === null || $at->gt($lastAt)) {
+                        $lastAt = $at;
+                    }
+                } catch (\Throwable) {
+                    $days = 0.0;
+                }
+            }
+            $w = pow(0.5, $days / self::HALF_LIFE_DAYS);
+
+            $weightedAttempts += $w;
+            $weightedWrong += $w * $incorrect;
+            $weightedExpectedWrong += $w * $baselineErr;
+
+            $key = trim($difficultyCategory) !== '' ? trim($difficultyCategory) : 'unknown';
+            $byCategory[$key] ??= [
+                'attempts' => 0,
+                'wrong' => 0,
+                'weighted_attempts' => 0.0,
+                'weighted_wrong' => 0.0,
+                'baseline_error_rate' => $baselineErr,
+            ];
+            $byCategory[$key]['attempts']++;
+            $byCategory[$key]['wrong'] += $incorrect;
+            $byCategory[$key]['weighted_attempts'] += $w;
+            $byCategory[$key]['weighted_wrong'] += $w * $incorrect;
+        }
+
+        $weightedErrorRate = $weightedAttempts > 0 ? ($weightedWrong / $weightedAttempts) : null;
+        $weightedExpectedErrorRate = $weightedAttempts > 0 ? ($weightedExpectedWrong / $weightedAttempts) : null;
+        $residual = ($weightedErrorRate !== null && $weightedExpectedErrorRate !== null)
+            ? ($weightedErrorRate - $weightedExpectedErrorRate)
+            : 0.0;
+
+        $adaptive = $this->buildAdaptivePolicy(
+            $weightedAttempts,
+            $weightedErrorRate,
+            $weightedExpectedErrorRate,
+            $residual
+        );
+        $residualGain = (float) $adaptive['residual_gain'];
+        $residualScaleDenom = (float) $adaptive['residual_scale_denom'];
+        $shrinkageM0 = (float) $adaptive['shrinkage_m0'];
+
+        if ($weightedAttempts < 8) {
+            $stepLimit = 0.0;
+        } elseif ($weightedAttempts < 20) {
+            $stepLimit = 0.08;
+        } elseif ($weightedAttempts < 60) {
+            $stepLimit = 0.15;
+        } else {
+            $stepLimit = 0.25;
+        }
+        $residualScale = min(1.0, abs($residual) / max(1e-6, $residualScaleDenom));
+        $effectiveStep = $stepLimit * $residualScale;
+
+        $targetDifficulty = $this->clamp(
+            $originalDifficulty + $residualGain * $residual,
+            self::MIN_DIFF,
+            self::MAX_DIFF
+        );
+        $candidateDifficulty = $originalDifficulty + $this->clamp(
+            $targetDifficulty - $originalDifficulty,
+            -$effectiveStep,
+            $effectiveStep
+        );
+        $candidateDifficulty = $this->clamp($candidateDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+        $calibratedDifficulty = ($weightedAttempts < 8)
+            ? $originalDifficulty
+            : (
+                ($shrinkageM0 * $originalDifficulty + $weightedAttempts * $candidateDifficulty)
+                / ($shrinkageM0 + $weightedAttempts)
+            );
+        $calibratedDifficulty = $this->clamp($calibratedDifficulty, self::MIN_DIFF, self::MAX_DIFF);
+
+        foreach ($byCategory as $cat => $stats) {
+            $wN = (float) ($stats['weighted_attempts'] ?? 0.0);
+            $wWrong = (float) ($stats['weighted_wrong'] ?? 0.0);
+            $n = (int) ($stats['attempts'] ?? 0);
+            $wrong = (int) ($stats['wrong'] ?? 0);
+            $byCategory[$cat]['error_rate'] = $n > 0 ? round($wrong / $n, 4) : null;
+            $byCategory[$cat]['weighted_error_rate'] = $wN > 0 ? round($wWrong / $wN, 4) : null;
+            $byCategory[$cat]['weighted_attempts'] = round($wN, 4);
+            $byCategory[$cat]['weighted_wrong'] = round($wWrong, 4);
+            $byCategory[$cat]['baseline_error_rate'] = round((float) ($stats['baseline_error_rate'] ?? 0.5), 4);
+        }
+
+        return [
+            'attempts' => count($attempts),
+            'correct_count' => $correctCount,
+            'wrong_count' => $wrongCount,
+            'weighted_attempts' => $weightedAttempts,
+            'weighted_wrong' => $weightedWrong,
+            'weighted_error_rate' => $weightedErrorRate,
+            'last_graded_at' => $lastAt?->toDateTimeString(),
+            'calibrated_difficulty' => $calibratedDifficulty,
+            'meta' => [
+                'algorithm' => self::ALGO,
+                'question_type' => $questionType,
+                'original_difficulty' => round($originalDifficulty, 4),
+                'half_life_days' => self::HALF_LIFE_DAYS,
+                'weighted_expected_error_rate' => $weightedExpectedErrorRate !== null
+                    ? round($weightedExpectedErrorRate, 4)
+                    : null,
+                'residual' => round($residual, 4),
+                'residual_gain' => round($residualGain, 4),
+                'residual_scale_denom' => round($residualScaleDenom, 4),
+                'step_limit' => round($stepLimit, 4),
+                'residual_scale' => round($residualScale, 4),
+                'effective_step' => round($effectiveStep, 4),
+                'target_difficulty' => round($targetDifficulty, 4),
+                'candidate_difficulty' => round($candidateDifficulty, 4),
+                'shrinkage_m0' => round($shrinkageM0, 4),
+                'adaptive_policy' => $adaptive,
+                'by_difficulty_category' => $byCategory,
+            ],
+        ];
+    }
+
+    /**
+     * 基于使用中的样本质量自动调整超参数,无需人工干预。
+     *
+     * @return array{residual_gain:float,residual_scale_denom:float,shrinkage_m0:float,confidence:float,signal_strength:float}
+     */
+    private function buildAdaptivePolicy(
+        float $weightedAttempts,
+        ?float $weightedErrorRate,
+        ?float $weightedExpectedErrorRate,
+        float $residual
+    ): array {
+        $confidence = min(1.0, max(0.0, $weightedAttempts / 80.0));
+
+        // 信号强度由残差大小决定,残差越显著,收敛越快。
+        $signalStrength = min(1.0, abs($residual) / 0.25);
+
+        // 观测与期望偏差显著且样本充足时,提高 gain。
+        $residualGain = self::RESIDUAL_GAIN_MIN
+            + (self::RESIDUAL_GAIN_MAX - self::RESIDUAL_GAIN_MIN) * (0.55 * $confidence + 0.45 * $signalStrength);
+
+        // 样本越充足,越敏感;信号越强,越敏感。
+        $residualScaleDenom = self::RESIDUAL_SCALE_DENOM_MAX
+            - (self::RESIDUAL_SCALE_DENOM_MAX - self::RESIDUAL_SCALE_DENOM_MIN) * (0.6 * $confidence + 0.4 * $signalStrength);
+
+        // 收缩强度随置信度下降:样本少时强收缩,样本多时弱收缩。
+        $shrinkageM0 = self::SHRINKAGE_M0_MAX
+            - (self::SHRINKAGE_M0_MAX - self::SHRINKAGE_M0_MIN) * $confidence;
+
+        // 若观测与期望非常接近,适度回拉避免无意义振荡。
+        if ($weightedErrorRate !== null && $weightedExpectedErrorRate !== null && abs($weightedErrorRate - $weightedExpectedErrorRate) < 0.01) {
+            $residualGain = max(self::RESIDUAL_GAIN_MIN, $residualGain * 0.75);
+            $residualScaleDenom = min(self::RESIDUAL_SCALE_DENOM_MAX, $residualScaleDenom * 1.15);
+            $shrinkageM0 = min(self::SHRINKAGE_M0_MAX, $shrinkageM0 * 1.10);
+        }
+
+        return [
+            'residual_gain' => $this->clamp($residualGain, self::RESIDUAL_GAIN_MIN, self::RESIDUAL_GAIN_MAX),
+            'residual_scale_denom' => $this->clamp($residualScaleDenom, self::RESIDUAL_SCALE_DENOM_MIN, self::RESIDUAL_SCALE_DENOM_MAX),
+            'shrinkage_m0' => $this->clamp($shrinkageM0, self::SHRINKAGE_M0_MIN, self::SHRINKAGE_M0_MAX),
+            'confidence' => round($confidence, 4),
+            'signal_strength' => round($signalStrength, 4),
+        ];
+    }
+
+    /**
+     * @param  array<string,mixed>  $meta
+     * @param  array<string,mixed>  $event
+     * @return array<string,mixed>
+     */
+    private function appendRecentEvent(array $meta, array $event): array
+    {
+        $events = $meta['recent_events'] ?? [];
+        if (! is_array($events)) {
+            $events = [];
+        }
+        $events[] = $event;
+        if (count($events) > self::RECENT_EVENTS_LIMIT) {
+            $events = array_slice($events, -self::RECENT_EVENTS_LIMIT);
+        }
+        $meta['recent_events'] = $events;
+
+        return $meta;
+    }
+
+    /**
+     * @return array<string,mixed>
+     */
+    private function buildUpdateEvent(
+        float $outcomeError,
+        float $predBefore,
+        float $predAfter,
+        float $expectedErrorRate,
+        float $observedErrorRate,
+        float $residual,
+        Carbon $now
+    ): array {
+        $outcomeError = $this->clamp($outcomeError, 0.0, 1.0);
+        $p0 = $this->clamp($predBefore, 1e-6, 1.0 - 1e-6);
+        $p1 = $this->clamp($predAfter, 1e-6, 1.0 - 1e-6);
+
+        $brierBefore = ($p0 - $outcomeError) * ($p0 - $outcomeError);
+        $brierAfter = ($p1 - $outcomeError) * ($p1 - $outcomeError);
+        $loglossBefore = -($outcomeError * log($p0) + (1.0 - $outcomeError) * log(1.0 - $p0));
+        $loglossAfter = -($outcomeError * log($p1) + (1.0 - $outcomeError) * log(1.0 - $p1));
+
+        return [
+            'ts' => $now->toDateTimeString(),
+            'outcome_error' => round($outcomeError, 4),
+            'pred_before' => round($predBefore, 4),
+            'pred_after' => round($predAfter, 4),
+            'expected_error_rate' => round($expectedErrorRate, 4),
+            'observed_error_rate' => round($observedErrorRate, 4),
+            'residual' => round($residual, 4),
+            'abs_residual' => round(abs($residual), 4),
+            'brier_before' => round($brierBefore, 6),
+            'brier_after' => round($brierAfter, 6),
+            'logloss_before' => round($loglossBefore, 6),
+            'logloss_after' => round($loglossAfter, 6),
+        ];
+    }
+
+    private function getHealthScaleForType(string $questionType): float
+    {
+        $type = trim($questionType) !== '' ? trim($questionType) : 'unknown';
+        $cacheKey = 'difficulty_health_scale:'.$type;
+
+        return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($type) {
+            $rows = DB::table(self::TABLE)
+                ->where('updated_at', '>=', now()->subDays(14))
+                ->select(['algorithm_meta'])
+                ->get();
+
+            $currResiduals = [];
+            $prevResiduals = [];
+            $brierDelta = 0.0;
+            $loglossDelta = 0.0;
+            $eventCount = 0;
+            $nowTs = time();
+            $cutTs = $nowTs - 7 * 86400;
+
+            foreach ($rows as $row) {
+                $meta = json_decode((string) ($row->algorithm_meta ?? ''), true);
+                if (! is_array($meta)) {
+                    continue;
+                }
+                if (($meta['question_type'] ?? 'unknown') !== $type) {
+                    continue;
+                }
+                $events = $meta['recent_events'] ?? [];
+                if (! is_array($events)) {
+                    continue;
+                }
+
+                foreach ($events as $e) {
+                    if (! is_array($e)) {
+                        continue;
+                    }
+                    $ts = isset($e['ts']) ? strtotime((string) $e['ts']) : false;
+                    if ($ts === false) {
+                        continue;
+                    }
+                    $absResidual = abs((float) ($e['residual'] ?? 0.0));
+                    if ($ts >= $cutTs) {
+                        $currResiduals[] = $absResidual;
+                        $brierDelta += (float) ($e['brier_after'] ?? 0.0) - (float) ($e['brier_before'] ?? 0.0);
+                        $loglossDelta += (float) ($e['logloss_after'] ?? 0.0) - (float) ($e['logloss_before'] ?? 0.0);
+                        $eventCount++;
+                    } else {
+                        $prevResiduals[] = $absResidual;
+                    }
+                }
+            }
+
+            if ($eventCount < 80) {
+                return 1.0;
+            }
+
+            $avgBrierDelta = $brierDelta / max(1, $eventCount);
+            $avgLoglossDelta = $loglossDelta / max(1, $eventCount);
+            $medianCurrent = $this->median($currResiduals);
+            $medianPrev = $this->median($prevResiduals);
+
+            $scale = 1.0;
+            if ($avgBrierDelta > 0.0 && $avgLoglossDelta > 0.0) {
+                $scale *= 0.78;
+            }
+            if ($avgBrierDelta > 0.003 || $avgLoglossDelta > 0.01) {
+                $scale *= 0.82;
+            }
+            if ($medianPrev !== null && $medianPrev > 0.0 && $medianCurrent !== null && $medianCurrent > $medianPrev * 1.05) {
+                $scale *= 0.82;
+            }
+
+            $scale = $this->clamp($scale, 0.45, 1.0);
+
+            Log::info('QuestionDifficultyCalibrationService: 在线健康监控快照', [
+                'question_type' => $type,
+                'events_7d' => $eventCount,
+                'avg_brier_delta' => round($avgBrierDelta, 6),
+                'avg_logloss_delta' => round($avgLoglossDelta, 6),
+                'median_abs_residual_7d' => $medianCurrent !== null ? round($medianCurrent, 6) : null,
+                'median_abs_residual_prev_7d' => $medianPrev !== null ? round($medianPrev, 6) : null,
+                'health_scale' => round($scale, 3),
+            ]);
+
+            return $scale;
+        });
+    }
+
+    /**
+     * @param  array<int,float>  $values
+     */
+    private function median(array $values): ?float
+    {
+        if ($values === []) {
+            return null;
+        }
+        sort($values);
+        $n = count($values);
+        $m = intdiv($n, 2);
+        if ($n % 2 === 1) {
+            return (float) $values[$m];
+        }
+
+        return ((float) $values[$m - 1] + (float) $values[$m]) / 2.0;
+    }
+
+    private function normalizeDifficultyValue(mixed $value): ?float
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        $raw = (float) $value;
+        if ($raw > 1.0) {
+            $raw = $raw / 5.0;
+        }
+
+        return $this->clamp($raw, self::MIN_DIFF, self::MAX_DIFF);
+    }
+
+    private function clamp(float $value, float $min, float $max): float
+    {
+        return max($min, min($max, $value));
+    }
+
+    private function isReady(): bool
+    {
+        if ($this->tableReady !== null) {
+            return $this->tableReady;
+        }
+
+        $this->tableReady = Schema::hasTable('paper_questions')
+            && Schema::hasTable('papers')
+            && Schema::hasTable('questions')
+            && Schema::hasTable(self::TABLE);
+
+        return $this->tableReady;
+    }
+}

+ 438 - 29
app/Services/DiagnosticChapterService.php

@@ -300,18 +300,224 @@ class DiagnosticChapterService
     public function hasChapterDiagnostic(int $studentId, int $chapterId): bool
     {
         return \App\Models\Paper::query()
-            ->where('student_id', $studentId)
+            ->where('student_id', (string) $studentId)
             ->where('paper_type', 0) // 摸底类型
             ->where('diagnostic_chapter_id', $chapterId)
             ->exists();
     }
 
     /**
-     * 获取第一个未摸底的章节
-     * 用于章节摸底流程
+     * 获取章节摸底目标章节
+     * - 传入 targetChapterIds: 先在指定章节内按顺序找第一个未摸底章节;
+     *   若指定章节都已摸底,则在该章节所属教材内继续按顺序找未摸底章节。
+     * - 未传 targetChapterIds: 保持原逻辑,找教材内第一个未摸底章节。
      */
-    public function getFirstUndiagnosedChapter(int $textbookId, int $studentId): ?array
+    public function getFirstUndiagnosedChapter(int $textbookId, int $studentId, ?array $targetChapterIds = null): ?array
     {
+        $targetChapterIds = is_array($targetChapterIds)
+            ? array_values(array_unique(array_filter(array_map('intval', $targetChapterIds), fn ($id) => $id > 0)))
+            : [];
+        $payloadCache = [];
+
+        // 指定章节摸底流程:以传入章节为锚点。
+        if (!empty($targetChapterIds)) {
+            // 兜底:允许传入 section/subsection,自动向上映射到 chapter
+            $targetChapterIds = $this->normalizeToChapterIds($targetChapterIds);
+            if (empty($targetChapterIds)) {
+                Log::warning('DiagnosticChapterService: 指定章节参数无可解析chapter,终止指定章节摸底', [
+                    'textbook_id' => $textbookId,
+                    'student_id' => $studentId,
+                ]);
+
+                return null;
+            }
+
+            // 保持传入顺序,首个有效章作为锚点
+            $chapterMap = TextbookCatalog::query()
+                ->whereIn('id', $targetChapterIds)
+                ->where('node_type', 'chapter')
+                ->get(['id', 'textbook_id', 'title', 'parent_id', 'node_type', 'sort_order', 'display_no'])
+                ->keyBy('id');
+
+            $anchorChapter = null;
+            foreach ($targetChapterIds as $chapterId) {
+                $candidate = $chapterMap->get($chapterId);
+                if ($candidate) {
+                    $anchorChapter = $candidate;
+                    break;
+                }
+            }
+
+            if (!$anchorChapter) {
+                Log::warning('DiagnosticChapterService: 指定章节未找到有效chapter节点', [
+                    'student_id' => $studentId,
+                    'target_chapter_ids' => $targetChapterIds,
+                ]);
+                return null;
+            }
+
+            $anchorTextbookId = (int) ($anchorChapter->textbook_id ?? $textbookId);
+            $chaptersInTextbook = TextbookCatalog::query()
+                ->where('textbook_id', $anchorTextbookId)
+                ->where('node_type', 'chapter')
+                ->orderBy('sort_order')
+                ->orderBy('display_no')
+                ->orderBy('id')
+                ->get();
+
+            if ($chaptersInTextbook->isEmpty()) {
+                Log::warning('DiagnosticChapterService: 指定章节所属教材无章节', [
+                    'textbook_id' => $anchorTextbookId,
+                    'student_id' => $studentId,
+                    'target_chapter_ids' => $targetChapterIds,
+                ]);
+                return null;
+            }
+
+            $chaptersInTextbookIds = $chaptersInTextbook->pluck('id')->map(fn ($id) => (int) $id)->all();
+            $diagnosedSet = $this->getDiagnosedChapterIdSet($studentId, $chaptersInTextbookIds);
+
+            // 全教材都摸底:输入什么章就输出什么章(is_restart=true)
+            $allDiagnosed = true;
+            foreach ($chaptersInTextbookIds as $chapterId) {
+                if (!isset($diagnosedSet[$chapterId])) {
+                    $allDiagnosed = false;
+                    break;
+                }
+            }
+
+            $anchorPayload = $this->buildChapterPayload((int) $anchorChapter->id, $anchorChapter, $payloadCache);
+            if ($allDiagnosed) {
+                if ($anchorPayload !== null) {
+                    Log::info('DiagnosticChapterService: 全教材已摸底,返回输入锚点章节并重启', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'anchor_chapter_id' => $anchorChapter->id,
+                    ]);
+                    $anchorPayload['is_restart'] = true;
+                    return $anchorPayload;
+                }
+
+                // 兜底返回教材第一章(is_restart=true)
+                $firstChapter = $chaptersInTextbook->first();
+                if ($firstChapter) {
+                    $firstPayload = $this->buildChapterPayload((int) $firstChapter->id, $firstChapter, $payloadCache);
+                    if ($firstPayload !== null) {
+                        $firstPayload['is_restart'] = true;
+                        Log::info('DiagnosticChapterService: 全教材已摸底且输入章无有效题,兜底第一章重启', [
+                            'textbook_id' => $anchorTextbookId,
+                            'student_id' => $studentId,
+                            'chapter_id' => $firstChapter->id,
+                        ]);
+                        return $firstPayload;
+                    }
+                }
+
+                $widePayload = $this->buildTextbookWideChapterPayload($chaptersInTextbook);
+                if ($widePayload !== null) {
+                    $widePayload['is_restart'] = true;
+                    Log::info('DiagnosticChapterService: 全教材已摸底且单章无有效题,合并全教材知识点重启', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'diagnostic_chapter_id' => $widePayload['chapter_id'],
+                        'kp_count' => count($widePayload['kp_codes']),
+                        'section_count' => count($widePayload['section_ids']),
+                    ]);
+
+                    return $widePayload;
+                }
+
+                return null;
+            }
+
+            // 未全教材摸底:优先检查锚点章,未达标就仍以该章摸底
+            if ($anchorPayload !== null) {
+                $allMastered = StudentKnowledgeMastery::allAtLeastSkipNoQuestions(
+                    $studentId,
+                    $anchorPayload['kp_codes'],
+                    0.9
+                );
+
+                if (!$allMastered) {
+                    Log::info('DiagnosticChapterService: 锚点章节未达标,继续以该章摸底', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'anchor_chapter_id' => $anchorChapter->id,
+                        'kp_count' => count($anchorPayload['kp_codes']),
+                    ]);
+                    return $anchorPayload;
+                }
+            }
+
+            // 锚点达标后,按教材顺序向后找第一个未摸底章节
+            $anchorIndex = $chaptersInTextbook->search(fn ($c) => (int) $c->id === (int) $anchorChapter->id);
+            $anchorIndex = $anchorIndex === false ? 0 : (int) $anchorIndex;
+
+            for ($i = $anchorIndex + 1; $i < $chaptersInTextbook->count(); $i++) {
+                $chapter = $chaptersInTextbook[$i];
+                if (isset($diagnosedSet[(int) $chapter->id])) {
+                    continue;
+                }
+                $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
+                if ($payload === null) {
+                    continue;
+                }
+
+                Log::info('DiagnosticChapterService: 锚点章节已达标,向后推进到未摸底章节', [
+                    'textbook_id' => $anchorTextbookId,
+                    'student_id' => $studentId,
+                    'anchor_chapter_id' => $anchorChapter->id,
+                    'next_chapter_id' => $chapter->id,
+                ]);
+                return $payload;
+            }
+
+            // 若后续没有可用未摸底章,再从教材起点补找未摸底章
+            for ($i = 0; $i <= $anchorIndex; $i++) {
+                $chapter = $chaptersInTextbook[$i];
+                if (isset($diagnosedSet[(int) $chapter->id])) {
+                    continue;
+                }
+                $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
+                if ($payload !== null) {
+                    Log::info('DiagnosticChapterService: 锚点后无未摸底章,回到教材前序未摸底章节', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'anchor_chapter_id' => $anchorChapter->id,
+                        'fallback_chapter_id' => $chapter->id,
+                    ]);
+                    return $payload;
+                }
+            }
+
+            // 理论兜底:返回输入章并重启
+            if ($anchorPayload !== null) {
+                $anchorPayload['is_restart'] = true;
+                Log::info('DiagnosticChapterService: 指定章节流程兜底返回输入章节重启', [
+                    'textbook_id' => $anchorTextbookId,
+                    'student_id' => $studentId,
+                    'anchor_chapter_id' => $anchorChapter->id,
+                ]);
+                return $anchorPayload;
+            }
+
+            // 未全摸底但锚点无题且后续未摸底章也无题:仍尝试全教材合并(含子知识点扩展)
+            $widePayload = $this->buildTextbookWideChapterPayload($chaptersInTextbook);
+            if ($widePayload !== null) {
+                Log::info('DiagnosticChapterService: 指定章节流程未全摸底但单章无有效题,合并全教材知识点', [
+                    'textbook_id' => $anchorTextbookId,
+                    'student_id' => $studentId,
+                    'anchor_chapter_id' => $anchorChapter->id,
+                    'kp_count' => count($widePayload['kp_codes']),
+                    'section_count' => count($widePayload['section_ids']),
+                ]);
+
+                return $widePayload;
+            }
+
+            return null;
+        }
+
         $chapters = TextbookCatalog::query()
             ->where('textbook_id', $textbookId)
             ->where('node_type', 'chapter')
@@ -324,15 +530,14 @@ class DiagnosticChapterService
             return null;
         }
 
+        $chapterIds = $chapters->pluck('id')->map(fn ($id) => (int) $id)->all();
+        $diagnosedSet = $this->getDiagnosedChapterIdSet($studentId, $chapterIds);
+
         foreach ($chapters as $chapter) {
             // 检查是否已摸底
-            if (!$this->hasChapterDiagnostic($studentId, $chapter->id)) {
-                $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
-
-                // 过滤掉没有题目的知识点
-                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
-
-                if (empty($kpCodesWithQuestions)) {
+            if (!isset($diagnosedSet[(int) $chapter->id])) {
+                $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
+                if ($payload === null) {
                     // 这个章节没有题目,跳过
                     continue;
                 }
@@ -341,37 +546,241 @@ class DiagnosticChapterService
                     'textbook_id' => $textbookId,
                     'student_id' => $studentId,
                     'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? '',
-                    'kp_count' => count($kpCodesWithQuestions),
+                    'chapter_name' => $chapter->name ?? $chapter->title ?? '',
+                    'kp_count' => count($payload['kp_codes']),
                 ]);
 
-                return [
-                    'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? '',
-                    'section_ids' => $chapterData['section_ids'],
-                    'kp_codes' => $kpCodesWithQuestions,
-                ];
+                return $payload;
             }
         }
 
         // 所有章节都已摸底,返回第一章(重新开始)
         $firstChapter = $chapters->first();
-        $chapterData = $this->getChapterKnowledgePointsSimple($firstChapter->id);
-        $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+        $firstPayload = $firstChapter ? $this->buildChapterPayload((int) $firstChapter->id, $firstChapter, $payloadCache) : null;
+        if ($firstPayload !== null) {
+            Log::info('DiagnosticChapterService: 所有章节都已摸底,返回第一章', [
+                'textbook_id' => $textbookId,
+                'student_id' => $studentId,
+                'chapter_id' => $firstChapter->id,
+            ]);
+            $firstPayload['is_restart'] = true;
 
-        Log::info('DiagnosticChapterService: 所有章节都已摸底,返回第一章', [
-            'textbook_id' => $textbookId,
-            'student_id' => $studentId,
-            'chapter_id' => $firstChapter->id,
-        ]);
+            return $firstPayload;
+        }
+
+        $widePayload = $this->buildTextbookWideChapterPayload($chapters);
+        if ($widePayload !== null) {
+            $widePayload['is_restart'] = true;
+            Log::info('DiagnosticChapterService: 所有章节已摸底且第一章无有效题,合并全教材知识点重启', [
+                'textbook_id' => $textbookId,
+                'student_id' => $studentId,
+                'diagnostic_chapter_id' => $widePayload['chapter_id'],
+                'kp_count' => count($widePayload['kp_codes']),
+                'section_count' => count($widePayload['section_ids']),
+            ]);
+
+            return $widePayload;
+        }
+
+        return null;
+    }
+
+    /**
+     * 将传入节点ID(chapter/section/subsection)统一映射为所属 chapter ID 列表。
+     * 保持传入顺序去重,忽略不属于当前教材的节点。
+     *
+     * @param array<int> $nodeIds
+     * @return array<int>
+     */
+    private function normalizeToChapterIds(array $nodeIds): array
+    {
+        if (empty($nodeIds)) {
+            return [];
+        }
+
+        $chapterIds = [];
+        $seen = [];
+
+        foreach ($nodeIds as $nodeId) {
+            $currentId = (int) $nodeId;
+            if ($currentId <= 0) {
+                continue;
+            }
+
+            $guard = 0;
+            while ($currentId > 0 && $guard++ < 10) {
+                $node = TextbookCatalog::query()
+                    ->where('id', $currentId)
+                    ->first(['id', 'parent_id', 'node_type']);
+
+                if (!$node) {
+                    break;
+                }
+
+                if ($node->node_type === 'chapter') {
+                    if (!isset($seen[$node->id])) {
+                        $seen[$node->id] = true;
+                        $chapterIds[] = (int) $node->id;
+                    }
+                    break;
+                }
+
+                $currentId = (int) ($node->parent_id ?? 0);
+            }
+        }
+
+        if (count($chapterIds) !== count($nodeIds)) {
+            Log::info('DiagnosticChapterService: 章节参数已自动映射到chapter节点', [
+                'input_ids' => $nodeIds,
+                'resolved_chapter_ids' => $chapterIds,
+            ]);
+        }
+
+        return $chapterIds;
+    }
+
+    /**
+     * 按教材章节顺序合并全部 section 与知识点,再过滤有题知识点。
+     * 用于「全章已摸底」且单章(含第一章)无可用题时的兜底。
+     *
+     * @param  iterable<int, \App\Models\TextbookCatalog>  $chapters
+     */
+    private function buildTextbookWideChapterPayload(iterable $chapters): ?array
+    {
+        $firstChapter = null;
+        $allSectionIds = [];
+        $seenSection = [];
+        $orderedKp = [];
+        $seenKp = [];
+
+        foreach ($chapters as $chapter) {
+            if ($firstChapter === null) {
+                $firstChapter = $chapter;
+            }
+
+            $chapterId = (int) $chapter->id;
+            $chapterData = $this->getChapterKnowledgePointsSimple($chapterId);
+
+            foreach ($chapterData['section_ids'] as $sid) {
+                $sid = (int) $sid;
+                if ($sid <= 0 || isset($seenSection[$sid])) {
+                    continue;
+                }
+                $seenSection[$sid] = true;
+                $allSectionIds[] = $sid;
+            }
+
+            foreach ($chapterData['kp_codes'] as $kpCode) {
+                if ($kpCode === null || $kpCode === '') {
+                    continue;
+                }
+                if (isset($seenKp[$kpCode])) {
+                    continue;
+                }
+                $seenKp[$kpCode] = true;
+                $orderedKp[] = $kpCode;
+            }
+        }
+
+        if ($firstChapter === null) {
+            return null;
+        }
+
+        // 节上多为父知识点编码,题目可能挂在子知识点上;合并兜底时扩展子 KP 再筛有题
+        if (!empty($orderedKp)) {
+            $orderedKp = $this->expandWithChildKnowledgePoints($orderedKp);
+        }
+
+        $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($orderedKp);
+        if (empty($kpCodesWithQuestions)) {
+            return null;
+        }
 
         return [
-            'chapter_id' => $firstChapter->id,
-            'chapter_name' => $firstChapter->name ?? '',
+            'chapter_id' => (int) $firstChapter->id,
+            'chapter_name' => $firstChapter->name ?? $firstChapter->title ?? '',
+            'section_ids' => $allSectionIds,
+            'kp_codes' => $kpCodesWithQuestions,
+        ];
+    }
+
+    /**
+     * 根据 chapter_id 生成摸底章节载荷,若章节无可用题目知识点则返回 null。
+     */
+    private function buildChapterPayload(int $chapterId, $chapter = null, ?array &$payloadCache = null): ?array
+    {
+        if (is_array($payloadCache) && array_key_exists($chapterId, $payloadCache)) {
+            return $payloadCache[$chapterId];
+        }
+
+        if ($chapter === null) {
+            $chapter = TextbookCatalog::query()
+                ->where('id', $chapterId)
+                ->where('node_type', 'chapter')
+                ->first(['id', 'title']);
+        }
+
+        if (!$chapter) {
+            if (is_array($payloadCache)) {
+                $payloadCache[$chapterId] = null;
+            }
+            return null;
+        }
+
+        $chapterData = $this->getChapterKnowledgePointsSimple($chapterId);
+        $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+        if (empty($kpCodesWithQuestions)) {
+            if (is_array($payloadCache)) {
+                $payloadCache[$chapterId] = null;
+            }
+            return null;
+        }
+
+        $payload = [
+            'chapter_id' => $chapterId,
+            'chapter_name' => $chapter->name ?? $chapter->title ?? '',
             'section_ids' => $chapterData['section_ids'],
             'kp_codes' => $kpCodesWithQuestions,
-            'is_restart' => true, // 标记是重新开始
         ];
+        if (is_array($payloadCache)) {
+            $payloadCache[$chapterId] = $payload;
+        }
+
+        return $payload;
+    }
+
+    /**
+     * 批量获取学生在指定章节中的已摸底集合,避免循环 exists N+1 查询。
+     *
+     * @param array<int> $chapterIds
+     * @return array<int, bool>
+     */
+    private function getDiagnosedChapterIdSet(int $studentId, array $chapterIds): array
+    {
+        $chapterIds = array_values(array_unique(array_filter(array_map('intval', $chapterIds), fn ($id) => $id > 0)));
+        if (empty($chapterIds)) {
+            return [];
+        }
+
+        // papers.student_id 在模型中为 string,与整型混用可能导致部分环境匹配失败
+        $studentKey = (string) $studentId;
+
+        $ids = \App\Models\Paper::query()
+            ->where('student_id', $studentKey)
+            ->where('paper_type', 0)
+            ->whereIn('diagnostic_chapter_id', $chapterIds)
+            ->pluck('diagnostic_chapter_id')
+            ->map(fn ($id) => (int) $id)
+            ->unique()
+            ->values()
+            ->all();
+
+        $set = [];
+        foreach ($ids as $id) {
+            $set[$id] = true;
+        }
+
+        return $set;
     }
 
     /**

+ 25 - 13
app/Services/ExamAnswerAnalysisService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use App\Services\Analytics\QuestionDifficultyCalibrationService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 
@@ -28,7 +29,8 @@ class ExamAnswerAnalysisService
         private readonly MasteryCalculator $masteryCalculator,
         private readonly KnowledgeMasteryService $knowledgeMasteryService,
         private readonly LocalAIAnalysisService $aiAnalysisService,
-        private readonly MistakeBookService $mistakeBookService
+        private readonly MistakeBookService $mistakeBookService,
+        private readonly QuestionDifficultyCalibrationService $difficultyCalibrationService
     ) {}
 
     /**
@@ -65,6 +67,26 @@ class ExamAnswerAnalysisService
         $recordChangeState = $this->saveExamAnswerRecords($examData, $questionMappings);
         // 同步回写 paper_questions 判分结果,保证 PDF 与分析链路一致
         $this->syncPaperQuestionGrading($examData);
+        $hasAnswerChanged = (bool) ($recordChangeState['steps_changed'] ?? false)
+            || (bool) ($recordChangeState['questions_changed'] ?? false);
+        if ($hasAnswerChanged) {
+            try {
+                $updatedQuestions = $this->difficultyCalibrationService->updateOnlineFromPaper($paperId, $questions);
+                Log::info('ExamAnswerAnalysisService: 判卷后在线更新题目难度完成', [
+                    'paper_id' => $paperId,
+                    'updated_questions' => $updatedQuestions,
+                ]);
+            } catch (\Throwable $e) {
+                Log::warning('ExamAnswerAnalysisService: 判卷后在线更新题目难度失败,已忽略不阻断主流程', [
+                    'paper_id' => $paperId,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        } else {
+            Log::info('ExamAnswerAnalysisService: 本次答案无变化,跳过在线难度更新', [
+                'paper_id' => $paperId,
+            ]);
+        }
 
         // 同卷同答案重复提交:直接复用最近一次分析结果,避免掌握度被重复累计
         $forceRecalculate = boolval($examData['force_recalculate'] ?? false);
@@ -299,7 +321,6 @@ class ExamAnswerAnalysisService
             $question = DB::connection('mysql')
                 ->table('questions')
                 ->where('id', $questionId)
-                ->orWhere('question_code', $questionId)
                 ->first();
 
             if ($question) {
@@ -481,16 +502,12 @@ class ExamAnswerAnalysisService
             $questionsData = DB::connection('mysql')
                 ->table('questions')
                 ->whereIn('id', $questionIds)
-                ->orWhereIn('question_code', $questionIds)
                 ->get();
 
             // 建立 ID 到题目数据的映射
             $questionMap = [];
             foreach ($questionsData as $q) {
                 $questionMap[$q->id] = $q;
-                if ($q->question_code) {
-                    $questionMap[$q->question_code] = $q;
-                }
             }
 
             // 【第2步】收集所有直接关联的 kp_code
@@ -658,17 +675,13 @@ class ExamAnswerAnalysisService
             $questions = DB::connection('mysql')
                 ->table('questions')
                 ->whereIn('id', $questionIds)
-                ->orWhereIn('question_code', $questionIds)
-                ->select(['id', 'question_code', 'difficulty'])
+                ->select(['id', 'difficulty'])
                 ->get();
 
             foreach ($questions as $question) {
                 $difficulty = $question->difficulty !== null ? floatval($question->difficulty) : 0.6;
-                // 同时用 id 和 question_code 作为键,确保能匹配到
+                // 统一按 questions.id 作为唯一键,避免 question_code 映射歧义
                 $difficulties[$question->id] = $difficulty;
-                if ($question->question_code) {
-                    $difficulties[$question->question_code] = $difficulty;
-                }
             }
         } catch (\Exception $e) {
             Log::warning('批量获取题目难度失败', [
@@ -2181,7 +2194,6 @@ class ExamAnswerAnalysisService
             $question = DB::connection('mysql')
                 ->table('questions')
                 ->where('id', $questionId)
-                ->orWhere('question_code', $questionId)
                 ->first();
 
             if ($question && isset($question->score)) {

+ 325 - 3
app/Services/ExamPdfExportService.php

@@ -29,6 +29,14 @@ class ExamPdfExportService
 {
     private ?KatexRenderer $katexRenderer = null;
     private ?array $knowledgePointMetaCache = null;
+    private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
+    private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
+
+    /**
+     * @var array<string, array{w:int,h:int}|null>
+     */
+    private array $pdfImageDimensionCache = [];
+    private ?bool $hasPdfImageMetricsTable = null;
 
     public function __construct(
         private readonly LearningAnalyticsService $learningAnalyticsService,
@@ -89,7 +97,7 @@ class ExamPdfExportService
             return null;
         }
 
-        $pdfBinary = $this->buildPdf($html);
+        $pdfBinary = $this->buildPdf($html, ! $includeAnswer && ! $useGradingView);
         if ($pdfBinary === null || $pdfBinary === '') {
             Log::error('renderAndStoreExamPdf: buildPdf 失败', [
                 'paper_id' => $paperId,
@@ -200,7 +208,7 @@ class ExamPdfExportService
 
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
-            $pdfBinary = $this->buildPdf($unifiedHtml);
+            $pdfBinary = $this->buildPdf($unifiedHtml, true, true);
             if (! $pdfBinary) {
                 Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
 
@@ -2011,10 +2019,15 @@ class ExamPdfExportService
     /**
      * 构建PDF
      */
-    private function buildPdf(string $html): ?string
+    private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
     {
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
         $utf8Html = $this->ensureUtf8Html($html);
+        if ($applyWideImageSizing) {
+            $utf8Html = $scopeToExamPart
+                ? $this->applyAdaptiveWideImageSizingToExamPart($utf8Html)
+                : $this->applyAdaptiveWideImageSizing($utf8Html);
+        }
         $written = file_put_contents($tmpHtml, $utf8Html);
 
         Log::debug('ExamPdfExportService: HTML文件已创建', [
@@ -2037,6 +2050,213 @@ class ExamPdfExportService
         return $chromePdf;
     }
 
+    /**
+     * 对扁长/超扁长图片做全局自适应放大,普通图片不处理。
+     */
+    private function applyAdaptiveWideImageSizing(string $html): string
+    {
+        return (string) preg_replace_callback(
+            '/<img\b[^>]*\bsrc=(["\'])([^"\']+)\1[^>]*>/i',
+            function (array $m): string {
+                $fullTag = $m[0] ?? '';
+                $src = $m[2] ?? '';
+                if ($fullTag === '' || $src === '' || str_starts_with($src, 'data:')) {
+                    return $fullTag;
+                }
+                if (! $this->shouldApplyAdaptiveSizingToSrc($src)) {
+                    return $fullTag;
+                }
+
+                $dim = $this->getPdfImageDimensions($src);
+                if (! $dim || ($dim['w'] ?? 0) <= 0 || ($dim['h'] ?? 0) <= 0) {
+                    return $fullTag;
+                }
+
+                $ratio = $dim['w'] / max(1, $dim['h']);
+                if ($ratio < 2.8) {
+                    return $fullTag;
+                }
+
+                $targetWidth = $ratio >= 3.5 ? self::PDF_IMAGE_WIDTH_VERY_WIDE_PX : self::PDF_IMAGE_WIDTH_WIDE_PX;
+                $targetWidth = min($targetWidth, $dim['w']);
+                $targetStyle = sprintf(
+                    'width:%dpx!important;max-width:%dpx!important;max-height:60mm!important;height:auto!important;object-fit:contain!important;',
+                    $targetWidth,
+                    $targetWidth
+                );
+
+                if (preg_match('/\sstyle=(["\'])(.*?)\1/i', $fullTag, $sm)) {
+                    $originStyle = $sm[2] ?? '';
+                    $originStyle = preg_replace('/\bmax-width\s*:[^;]+;?/i', '', $originStyle);
+                    $originStyle = preg_replace('/\bmax-height\s*:[^;]+;?/i', '', $originStyle);
+                    $originStyle = preg_replace('/\bwidth\s*:[^;]+;?/i', '', $originStyle);
+                    $originStyle = preg_replace('/\bheight\s*:[^;]+;?/i', '', $originStyle);
+                    $originStyle = preg_replace('/\bobject-fit\s*:[^;]+;?/i', '', $originStyle);
+                    $newStyle = $targetStyle.trim((string) $originStyle);
+
+                    return preg_replace(
+                        '/\sstyle=(["\'])(.*?)\1/i',
+                        ' style="'.$newStyle.'"',
+                        $fullTag,
+                        1
+                    ) ?? $fullTag;
+                }
+
+                return preg_replace('/<img\b/i', '<img style="'.$targetStyle.'"', $fullTag, 1) ?? $fullTag;
+            },
+            $html
+        );
+    }
+
+    /**
+     * 仅在 unified HTML 的试卷容器中应用扁图策略,避免影响判卷/知识点讲解部分。
+     */
+    private function applyAdaptiveWideImageSizingToExamPart(string $html): string
+    {
+        $startMarker = '<!-- EXAM_PART_START -->';
+        $endMarker = '<!-- EXAM_PART_END -->';
+        $startPos = strpos($html, $startMarker);
+        $endPos = strpos($html, $endMarker);
+        if ($startPos === false || $endPos === false || $endPos <= $startPos) {
+            return $html;
+        }
+
+        $contentStart = $startPos + strlen($startMarker);
+        $examContent = substr($html, $contentStart, $endPos - $contentStart);
+        if ($examContent === false || $examContent === '') {
+            return $html;
+        }
+
+        $processedExamContent = $this->applyAdaptiveWideImageSizing($examContent);
+
+        return substr($html, 0, $contentStart).$processedExamContent.substr($html, $endPos);
+    }
+
+    /**
+     * @return array{w:int,h:int}|null
+     */
+    private function getPdfImageDimensions(string $src): ?array
+    {
+        if (array_key_exists($src, $this->pdfImageDimensionCache)) {
+            return $this->pdfImageDimensionCache[$src];
+        }
+
+        try {
+            $persisted = $this->getPersistedPdfImageMetrics($src);
+            if ($persisted !== null) {
+                $this->pdfImageDimensionCache[$src] = $persisted;
+
+                return $persisted;
+            }
+
+            if (! str_starts_with($src, 'http://') && ! str_starts_with($src, 'https://')) {
+                $this->pdfImageDimensionCache[$src] = null;
+
+                return null;
+            }
+
+            $size = @getimagesize($src);
+            if (is_array($size) && count($size) >= 2) {
+                $data = ['w' => (int) $size[0], 'h' => (int) $size[1]];
+                $this->persistPdfImageMetrics($src, $data);
+                $this->pdfImageDimensionCache[$src] = $data;
+
+                return $data;
+            }
+
+            $this->pdfImageDimensionCache[$src] = null;
+
+            return null;
+        } catch (\Throwable $e) {
+            Log::debug('ExamPdfExportService: 图片尺寸探测失败', ['src' => $src, 'error' => $e->getMessage()]);
+            $this->pdfImageDimensionCache[$src] = null;
+
+            return null;
+        }
+    }
+
+    /**
+     * @return array{w:int,h:int}|null
+     */
+    private function getPersistedPdfImageMetrics(string $src): ?array
+    {
+        if (! $this->isPdfImageMetricsTableReady()) {
+            return null;
+        }
+
+        $row = DB::table('pdf_image_metrics')
+            ->where('src', $src)
+            ->first(['width', 'height']);
+
+        if (! $row) {
+            return null;
+        }
+
+        $w = (int) ($row->width ?? 0);
+        $h = (int) ($row->height ?? 0);
+        if ($w <= 0 || $h <= 0) {
+            return null;
+        }
+
+        return ['w' => $w, 'h' => $h];
+    }
+
+    /**
+     * @param  array{w:int,h:int}  $data
+     */
+    private function persistPdfImageMetrics(string $src, array $data): void
+    {
+        if (! $this->isPdfImageMetricsTableReady()) {
+            return;
+        }
+
+        $w = (int) ($data['w'] ?? 0);
+        $h = (int) ($data['h'] ?? 0);
+        if ($w <= 0 || $h <= 0) {
+            return;
+        }
+
+        DB::table('pdf_image_metrics')->upsert([
+            [
+                'src' => $src,
+                'width' => $w,
+                'height' => $h,
+                'ratio' => round($w / max(1, $h), 4),
+                'updated_at' => now(),
+                'created_at' => now(),
+            ],
+        ], ['src'], ['width', 'height', 'ratio', 'updated_at']);
+    }
+
+    private function isPdfImageMetricsTableReady(): bool
+    {
+        if ($this->hasPdfImageMetricsTable !== null) {
+            return $this->hasPdfImageMetricsTable;
+        }
+
+        $this->hasPdfImageMetricsTable = Schema::hasTable('pdf_image_metrics');
+
+        return $this->hasPdfImageMetricsTable;
+    }
+
+    private function shouldApplyAdaptiveSizingToSrc(string $src): bool
+    {
+        $parts = parse_url($src);
+        if (! is_array($parts)) {
+            return false;
+        }
+        $host = strtolower((string) ($parts['host'] ?? ''));
+        $path = (string) ($parts['path'] ?? '');
+        if ($host !== 'file.chunsunqiuzhu.com') {
+            return false;
+        }
+        if (! str_contains($path, '/data/')) {
+            return false;
+        }
+
+        return (bool) preg_match('/\.(png|jpe?g|webp)$/i', $path);
+    }
+
     /**
      * 使用Chrome渲染PDF
      */
@@ -2708,10 +2928,12 @@ class ExamPdfExportService
 
         // 添加试卷部分
         $bodyContent .= '
+    <!-- EXAM_PART_START -->
     <!-- 试卷部分 - 连续显示 -->
     <div class="exam-part">
 '.$examBody.'
     </div>
+    <!-- EXAM_PART_END -->
 ';
 
         // 添加判卷部分
@@ -3711,6 +3933,96 @@ class ExamPdfExportService
         }
     }
 
+    /**
+     * 题目质检专用 PDF:固定使用判题卡体系模板(答案详解 + 判题卡)。
+     * 不进入正常组卷流程,仅用于检查题干、答案、解题思路渲染效果。
+     *
+     * @return array{pdf_url?: string}
+     */
+    public function generateQuestionCheckPdf(
+        object $paper,
+        array $groupedQuestions,
+        array $student = [],
+        array $teacher = []
+    ): array {
+        Log::info('generateQuestionCheckPdf 开始', [
+            'paper_id' => $paper->paper_id ?? null,
+            'question_counts' => [
+                'choice' => count($groupedQuestions['choice'] ?? []),
+                'fill' => count($groupedQuestions['fill'] ?? []),
+                'answer' => count($groupedQuestions['answer'] ?? []),
+            ],
+        ]);
+
+        $studentName = $student['name'] ?? ($paper->student_id ?? '________');
+        $examCode = \App\Support\PaperNaming::extractExamCode((string) ($paper->paper_id ?? 'custom'));
+        $pdfMeta = [
+            'student_name' => $studentName,
+            'exam_code' => $examCode,
+            'assemble_type_label' => '题目质检',
+            'header_title' => $examCode,
+            'exam_pdf_title' => '题目质检_'.$examCode,
+            'grading_pdf_title' => '题目质检_'.$examCode,
+            'knowledge_pdf_title' => '题目质检_'.$examCode,
+        ];
+
+        try {
+            // 固定走题目质检专用模板:题干+答案+解题思路 + 判题卡附页
+            $html = view('pdf.question-check', [
+                'paper' => $paper,
+                'questions' => $groupedQuestions,
+                'student' => $student,
+                'teacher' => $teacher,
+                'pdfMeta' => $pdfMeta,
+            ])->render();
+
+            if (empty(trim($html))) {
+                Log::error('generateQuestionCheckPdf: HTML 渲染为空', [
+                    'paper_id' => $paper->paper_id ?? null,
+                ]);
+
+                return [];
+            }
+
+            if ($this->katexRenderer) {
+                $html = $this->katexRenderer->renderHtml($html);
+            }
+
+            $pdfBinary = $this->buildPdf($this->ensureUtf8Html($html));
+            if (empty($pdfBinary)) {
+                Log::error('generateQuestionCheckPdf: buildPdf 失败', [
+                    'paper_id' => $paper->paper_id ?? null,
+                ]);
+
+                return [];
+            }
+
+            $path = 'custom_exams/'.($paper->paper_id ?? ('custom_'.time())).'.pdf';
+            $url = $this->pdfStorageService->put($path, $pdfBinary);
+            if (! $url) {
+                Log::error('generateQuestionCheckPdf: 上传失败', [
+                    'paper_id' => $paper->paper_id ?? null,
+                    'path' => $path,
+                ]);
+
+                return [];
+            }
+
+            return [
+                'pdf_url' => $url,
+                'grading_pdf_url' => $url,
+            ];
+        } catch (\Throwable $e) {
+            Log::error('generateQuestionCheckPdf 失败', [
+                'paper_id' => $paper->paper_id ?? null,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            throw $e;
+        }
+    }
+
     /**
      * 渲染自定义题目的HTML
      */
@@ -3769,6 +4081,16 @@ class ExamPdfExportService
     private function normalizeAnswerFieldForPdf(object $question): object
     {
         $normalizedQuestion = clone $question;
+        // 以 paper_questions.question_text 为标准题干字段,兼容旧链路 content。
+        $questionText = trim((string) ($normalizedQuestion->question_text ?? ''));
+        if ($questionText === '') {
+            $questionText = trim((string) ($normalizedQuestion->content ?? ''));
+        }
+        if ($questionText !== '') {
+            $normalizedQuestion->question_text = $questionText;
+            $normalizedQuestion->content = $questionText;
+        }
+
         $answerText = trim((string) ($normalizedQuestion->answer ?? ''));
         if ($answerText !== '') {
             return $normalizedQuestion;

+ 8 - 2
app/Services/ExamTypeStrategy.php

@@ -2029,8 +2029,14 @@ class ExamTypeStrategy
             return $this->buildGeneralParams($params);
         }
 
-        // 找第一个未摸底的章节
-        $chapterInfo = $diagnosticService->getFirstUndiagnosedChapter((int) $textbookId, $studentId);
+        // 找章节摸底目标:
+        // - 传了 chapter_id_list:按指定章节顺序找有题章节(允许重复摸底)
+        // - 未传:走默认未摸底优先逻辑
+        $chapterInfo = $diagnosticService->getFirstUndiagnosedChapter(
+            (int) $textbookId,
+            $studentId,
+            $params['chapter_id_list'] ?? null
+        );
 
         if (empty($chapterInfo) || empty($chapterInfo['kp_codes'])) {
             Log::warning('ExamTypeStrategy: 章节摸底未找到有效章节', [

+ 88 - 0
app/Services/QuestionDifficultyResolver.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class QuestionDifficultyResolver
+{
+    private const TABLE = 'question_difficulty_calibrations';
+
+    private ?bool $tableReady = null;
+
+    /**
+     * @param  array<int, int|string>  $questionIds
+     * @return array<int, float> question_bank_id => calibrated_difficulty
+     */
+    public function mapCalibratedDifficulty(array $questionIds): array
+    {
+        if (! $this->isReady()) {
+            return [];
+        }
+
+        $questionIds = collect($questionIds)
+            ->map(fn ($id) => (int) $id)
+            ->filter(fn ($id) => $id > 0)
+            ->unique()
+            ->values()
+            ->all();
+        if ($questionIds === []) {
+            return [];
+        }
+
+        return DB::table(self::TABLE)
+            ->whereIn('question_bank_id', $questionIds)
+            ->pluck('calibrated_difficulty', 'question_bank_id')
+            ->map(fn ($v) => (float) $v)
+            ->all();
+    }
+
+    /**
+     * 批量给题目数组覆盖 difficulty(校准值优先,原始值兜底)
+     *
+     * @param  array<int, array<string, mixed>>  $questions
+     * @return array<int, array<string, mixed>>
+     */
+    public function applyCalibratedDifficulty(array $questions): array
+    {
+        if ($questions === []) {
+            return $questions;
+        }
+
+        $ids = [];
+        foreach ($questions as $q) {
+            $id = (int) ($q['id'] ?? $q['question_id'] ?? $q['question_bank_id'] ?? 0);
+            if ($id > 0) {
+                $ids[] = $id;
+            }
+        }
+        $map = $this->mapCalibratedDifficulty($ids);
+        if ($map === []) {
+            return $questions;
+        }
+
+        foreach ($questions as &$q) {
+            $id = (int) ($q['id'] ?? $q['question_id'] ?? $q['question_bank_id'] ?? 0);
+            if ($id > 0 && array_key_exists($id, $map)) {
+                $q['difficulty'] = (float) $map[$id];
+                $q['difficulty_source'] = 'calibrated';
+            }
+        }
+        unset($q);
+
+        return $questions;
+    }
+
+    private function isReady(): bool
+    {
+        if ($this->tableReady !== null) {
+            return $this->tableReady;
+        }
+
+        $this->tableReady = Schema::hasTable(self::TABLE);
+
+        return $this->tableReady;
+    }
+}
+

+ 24 - 7
app/Services/QuestionLocalService.php

@@ -14,11 +14,17 @@ use Illuminate\Support\Str;
 class QuestionLocalService
 {
     private DifficultyDistributionService $difficultyDistributionService;
+    private QuestionDifficultyResolver $questionDifficultyResolver;
 
-    public function __construct(?DifficultyDistributionService $difficultyDistributionService = null)
+    public function __construct(
+        ?DifficultyDistributionService $difficultyDistributionService = null,
+        ?QuestionDifficultyResolver $questionDifficultyResolver = null
+    )
     {
         $this->difficultyDistributionService = $difficultyDistributionService
             ?? app(DifficultyDistributionService::class);
+        $this->questionDifficultyResolver = $questionDifficultyResolver
+            ?? app(QuestionDifficultyResolver::class);
     }
 
     public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array
@@ -614,6 +620,17 @@ class QuestionLocalService
             return [];
         }
 
+        $questions = $this->questionDifficultyResolver->applyCalibratedDifficulty($questions);
+        $calibratedCount = count(array_filter($questions, fn ($q) => ($q['difficulty_source'] ?? null) === 'calibrated'));
+        Log::info('QuestionLocalService: 组卷前应用校准难度', [
+            'total_candidates' => count($questions),
+            'calibrated_candidates' => $calibratedCount,
+        ]);
+
+        $resolveQuestionId = static function (array $question): string {
+            return (string) ($question['id'] ?? $question['question_id'] ?? $question['question_bank_id'] ?? '');
+        };
+
         // 【恢复】简化逻辑,避免复杂处理
         $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
 
@@ -651,8 +668,8 @@ class QuestionLocalService
             foreach ($bucket as $question) {
                 if ($taken >= $targetCount) break;
 
-                $questionId = $question['id'] ?? null;
-                if ($questionId && !in_array($questionId, $usedIds)) {
+                $questionId = $resolveQuestionId($question);
+                if ($questionId !== '' && !in_array($questionId, $usedIds, true)) {
                     $selected[] = $question;
                     $usedIds[] = $questionId;
                     $taken++;
@@ -700,8 +717,8 @@ class QuestionLocalService
                         break;
                     }
 
-                    $id = $q['id'] ?? null;
-                    if ($id && !in_array($id, $usedIds)) {
+                    $id = $resolveQuestionId($q);
+                    if ($id !== '' && !in_array($id, $usedIds, true)) {
                         $selected[] = $q;
                         $usedIds[] = $id;
                         $supplemented++;
@@ -712,8 +729,8 @@ class QuestionLocalService
             if ($supplemented < $needMore) {
                 $remaining = [];
                 foreach ($questions as $q) {
-                    $id = $q['id'] ?? null;
-                    if ($id && !in_array($id, $usedIds)) {
+                    $id = $resolveQuestionId($q);
+                    if ($id !== '' && !in_array($id, $usedIds, true)) {
                         $remaining[] = $q;
                     }
                 }

+ 66 - 12
app/Support/GradingMarkBoxCounter.php

@@ -15,24 +15,78 @@ class GradingMarkBoxCounter
         return max(1, $count);
     }
 
-    public function countAnswerSteps(?string $text): int
+    public function countAnswerSteps(?string $text, ?string $stem = null): int
     {
-        $text = (string) $text;
-        $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
+        $stepCount = $this->countExplicitAnswerSteps($text);
+        if ($stepCount > 0) {
+            return $stepCount;
+        }
+
+        return $this->inferSubQuestionCount($stem);
+    }
+
+    /**
+     * 判题卡解答题方框数:
+     * - 优先按题干小题编号系列计数((1)(2)...)
+     * - 若不存在系列小题,则固定 1 框(避免被解析步骤数放大)
+     */
+    public function countAnswerMarkBoxes(?string $stem, ?string $solution = null): int
+    {
+        $minFromStem = $this->inferSubQuestionCount($stem);
+        $stepCount = $this->countExplicitAnswerSteps($solution);
+
+        return max($minFromStem, $stepCount > 0 ? $stepCount : 1);
+    }
+
+    /**
+     * 仅识别“显式步骤标记”并计数:
+     * - 步骤1: / 步骤一:
+     * - 第1步: / 第一步:
+     * 要求位于段首(行首或标点后),降低误判。
+     */
+    private function countExplicitAnswerSteps(?string $text): int
+    {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return 0;
+        }
+
+        $text = preg_replace('/<br\s*\/?>/iu', "\n", $text) ?? $text;
+        $stepLabelPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?)';
+        $anchorPattern = '/(?:^|[\r\n。;;!?!?])\s*' . $stepLabelPattern . '/u';
+        preg_match_all($anchorPattern, $text, $matches);
+        $count = count($matches[0] ?? []);
 
-        if (!preg_match('/' . $stepPattern . '/u', $text)) {
+        return max(0, $count);
+    }
+
+    /**
+     * 当解析里没有步骤时,按题干中的小题“系列编号”估算判题卡方框数。
+     * 仅在至少出现 2 个编号时生效,避免误把 f(1) 这类数学表达当作小题。
+     */
+    private function inferSubQuestionCount(?string $stem): int
+    {
+        $stem = (string) $stem;
+        if ($stem === '') {
             return 1;
         }
 
-        $parts = preg_split('/(?=' . $stepPattern . ')/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
-        $count = 0;
-        foreach ($parts as $part) {
-            $stepText = trim((string) $part);
-            if ($stepText !== '' && preg_match('/^' . $stepPattern . '/u', $stepText)) {
-                $count++;
-            }
+        preg_match_all('/[((][1-9][0-9]*[))]/u', $stem, $allMarkers);
+        $markerCount = count($allMarkers[0] ?? []);
+        if ($markerCount < 2) {
+            return 1;
         }
 
-        return max(1, $count);
+        // 与题干显示层保持一致:把小题起始语境标准化到 <br>(n)
+        $normalized = preg_replace('/^\s*([((][1-9][0-9]*[))])\s*/u', '$1 ', $stem) ?? $stem;
+        $normalized = preg_replace('/([。;;!?!?::.])\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $normalized) ?? $normalized;
+        $normalized = preg_replace('/(?:(?:\\\\r\\\\n|\\\\n)|(?:\r?\n)|(?:<br\s*\/?>)|\s)+\s*([((][1-9][0-9]*[))])\s*/u', '<br>$1 ', $normalized) ?? $normalized;
+        $normalized = preg_replace('/(求出|求解|求|写出|计算|证明|判断|化简)\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $normalized) ?? $normalized;
+
+        // 统计“行首或换行后”的编号数量,作为子题数量
+        preg_match_all('/(?:^|<br>\s*)([((][1-9][0-9]*[))])/u', $normalized, $series);
+        $seriesCount = count($series[1] ?? []);
+
+        return max(1, $seriesCount);
     }
 }

+ 9 - 29
app/Support/OptionLayoutDecider.php

@@ -6,30 +6,8 @@ class OptionLayoutDecider
 {
     public function normalizeCompactMathForDisplay(string $option): string
     {
-        $trimmed = trim($option);
-        if ($trimmed === '') {
-            return $option;
-        }
-
-        $text = preg_replace('/^\$(.*)\$$/u', '$1', $trimmed) ?? $trimmed;
-        $parts = $this->extractSingleFractionParts($text);
-        if ($parts === null) {
-            return $option;
-        }
-
-        [$num, $den] = $parts;
-        $compactPart = '/^[\-+0-9a-zA-Z\x{221A}\\\\{}]+$/u';
-        if (
-            preg_match($compactPart, $num) !== 1
-            || preg_match($compactPart, $den) !== 1
-            || preg_match('/[=<>]/u', $num.$den) === 1
-            || $this->hasBinaryOperator($num)
-            || $this->hasBinaryOperator($den)
-        ) {
-            return $option;
-        }
-
-        return str_replace($text, $num.'/'.$den, $trimmed);
+        // 展示层保持数学标准分式(分子/分母上下结构),不做 \frac -> a/b 的文本替换
+        return $option;
     }
 
     /**
@@ -179,7 +157,7 @@ class OptionLayoutDecider
         $optionLength = $rawLength;
         $isSimpleCompactMath = preg_match('/^-?[0-9a-zA-Z\x{221A}]+(?:\/[0-9a-zA-Z\x{221A}]+)?$/u', $optionTextNoDollar) === 1;
         $isCompactLatexFraction = preg_match(
-            '/^\\\\d?frac\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}$/u',
+            '/^[+\-]?\\\\d?frac\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}\{[-+0-9a-zA-Z\\\\\x{221A}\^\(\)]+\}$/u',
             $optionTextNoDollar
         ) === 1;
         $isCompactLatexDegree = preg_match(
@@ -351,14 +329,16 @@ class OptionLayoutDecider
     }
 
     /**
-     * @return array{0:string,1:string}|null
+     * @return array{0:string,1:string,2:string}|null
      */
     private function extractSingleFractionParts(string $text): ?array
     {
-        if (! preg_match('/^\\\\d?frac/u', $text)) {
+        if (! preg_match('/^([+\-]?)(\\\\d?frac)/u', $text, $m)) {
             return null;
         }
-        $offset = preg_match('/^\\\\dfrac/u', $text) ? 6 : 5; // \dfrac or \frac
+        $prefix = (string) ($m[1] ?? '');
+        $fracCmd = (string) ($m[2] ?? '\\frac');
+        $offset = mb_strlen($prefix.$fracCmd, 'UTF-8');
         $len = mb_strlen($text, 'UTF-8');
 
         if ($offset >= $len || mb_substr($text, $offset, 1, 'UTF-8') !== '{') {
@@ -380,7 +360,7 @@ class OptionLayoutDecider
             return null;
         }
 
-        return [$num, $den];
+        return [$prefix, $num, $den];
     }
 
     private function hasBinaryOperator(string $expr): bool

+ 1 - 0
bootstrap/app.php

@@ -20,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
         \App\Console\Commands\SyncQuestionsFromQuestionBank::class,
         \App\Console\Commands\GenerateJudgeCardTemplateCommand::class,
         \App\Console\Commands\GenerateOptionLayoutRegressionCommand::class,
+        \App\Console\Commands\AnalyzeQuestionDifficultyCalibrationCommand::class,
     ])
     ->withMiddleware(function (Middleware $middleware): void {
         // 信任所有代理,允许读取 X-Forwarded-* 头

+ 26 - 0
database/migrations/2026_04_15_223000_create_pdf_image_metrics_table.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('pdf_image_metrics', function (Blueprint $table) {
+            $table->id();
+            $table->string('src', 512)->unique();
+            $table->unsignedInteger('width');
+            $table->unsignedInteger('height');
+            $table->decimal('ratio', 8, 4);
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('pdf_image_metrics');
+    }
+};
+

+ 38 - 0
database/migrations/2026_04_16_120000_create_question_difficulty_calibrations_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('question_difficulty_calibrations', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('question_bank_id')->unique()->comment('questions.id');
+            $table->decimal('original_difficulty', 6, 4)->nullable()->comment('归一化后的原始难度(0~1)');
+            $table->decimal('calibrated_difficulty', 6, 4)->comment('算法校准后难度(0~1)');
+            $table->decimal('difficulty_delta', 6, 4)->default(0)->comment('calibrated-original');
+            $table->unsignedInteger('attempts')->default(0);
+            $table->unsignedInteger('correct_count')->default(0);
+            $table->unsignedInteger('wrong_count')->default(0);
+            $table->decimal('weighted_attempts', 10, 4)->default(0);
+            $table->decimal('weighted_wrong', 10, 4)->default(0);
+            $table->decimal('weighted_error_rate', 6, 4)->nullable();
+            $table->timestamp('last_graded_at')->nullable();
+            $table->string('algorithm', 64)->default('rasch_1pl_map_v1');
+            $table->json('algorithm_meta')->nullable();
+            $table->timestamps();
+
+            $table->index('calibrated_difficulty', 'idx_qdc_calibrated_difficulty');
+            $table->index('updated_at', 'idx_qdc_updated_at');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('question_difficulty_calibrations');
+    }
+};
+

+ 19 - 1
resources/views/components/exam/paper-body.blade.php

@@ -407,6 +407,24 @@
         @php
             // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
             $questionNumber = $q->question_number ?? (count($choiceQuestions) + count($fillQuestions) + $index + 1);
+            // 解答题小题排版优化(仅在小题编号语境下换行,避免误伤 f(1) 这类函数表达)
+            $answerStem = (string) ($q->content ?? '');
+            preg_match_all('/[((][1-9][0-9]*[))]/u', $answerStem, $subQuestionMatches);
+            $subQuestionCount = count($subQuestionMatches[0] ?? []);
+            // 前提:只有出现“至少两个小题编号(形成系列)”才做自动换行
+            if ($subQuestionCount >= 2) {
+                // 开头的 (1)/(2) 不额外插入换行,只做标准化空格
+                $answerStem = preg_replace('/^\s*([((][1-9][0-9]*[))])\s*/u', '$1 ', $answerStem) ?? $answerStem;
+                // 句读后接小题编号:断行
+                $answerStem = preg_replace('/([。;;!?!?::.])\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $answerStem) ?? $answerStem;
+                // 换行簇(实际换行、字面量\\n、已有<br>)后接小题编号:统一压成单个 <br>
+                $answerStem = preg_replace('/(?:(?:\\\\r\\\\n|\\\\n)|(?:\r?\n)|(?:<br\s*\/?>)|\s)+\s*([((][1-9][0-9]*[))])\s*/u', '<br>$1 ', $answerStem) ?? $answerStem;
+                // 关键词引导的小题也换行,如 “求(1)…(2)… / 写出(1)…”
+                $answerStem = preg_replace('/(求出|求解|求|写出|计算|证明|判断|化简)\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>$2 ', $answerStem) ?? $answerStem;
+            }
+            $answerStemRendered = $mathProcessed
+                ? $answerStem
+                : \App\Services\MathFormulaProcessor::processFormulas($answerStem);
         @endphp
         <div class="question">
             <div class="question-grid">
@@ -425,7 +443,7 @@
                                 @endif
                             </span>
                         @endunless
-                        <span class="question-stem">{!! $mathProcessed ? $q->content : \App\Services\MathFormulaProcessor::processFormulas($q->content) !!}</span>
+                        <span class="question-stem">{!! $answerStemRendered !!}</span>
                     </div>
                 @endif
                 @unless($gradingMode)

+ 3 - 1
resources/views/pdf/exam-grading.blade.php

@@ -337,8 +337,10 @@
         }
         /* 选项中的图片样式 - 防止超出容器 */
         .option img {
-            max-width: 100%;
+            max-width: 92%;
+            max-height: 42mm;
             height: auto;
+            object-fit: contain;
             vertical-align: middle;
         }
         .question-stem .katex,

+ 4 - 38
resources/views/pdf/exam-paper.blade.php

@@ -98,43 +98,7 @@
             align-items: center;
             justify-content: center;
         }
-        /* 大题标题:不与后面内容分开 */
-        .section-title {
-            font-size: 16px;
-            font-weight: bold;
-            margin-top: 20px;
-            margin-bottom: 10px;
-            page-break-after: avoid;
-            break-after: avoid;
-        }
-        /* 题目整体:不分页 */
-        .question {
-            margin-bottom: 15px;
-            page-break-inside: avoid;
-            break-inside: avoid;
-            -webkit-column-break-inside: avoid;
-        }
-        /* 题目网格:不分页 */
-        .question-grid {
-            display: grid;
-            grid-template-columns: auto 1fr;
-            column-gap: 4px;
-            row-gap: 6px;
-            align-items: flex-start;
-            page-break-inside: avoid;
-            break-inside: avoid;
-        }
-        .question-lead {
-            display: flex;
-            gap: 4px;
-            align-items: flex-start;
-            font-weight: 600;
-            font-size: 14px;
-            line-height: 1.65;
-            margin-top: 1px;
-        }
-        .question-lead.spacer { visibility: hidden; }
-        .question-number { white-space: nowrap; margin-right: 2px; }
+        @include('pdf.partials.paper-body-core-styles')
         .grading-boxes { gap: 4px; flex-wrap: wrap; align-items: center; }
         .grading-boxes span { vertical-align: middle; }
         .question-main { font-size: 14px; line-height: 1.65; font-family: inherit; display: block; }
@@ -420,8 +384,10 @@
         }
         /* 选项中的图片样式 - 防止超出容器 */
         .option img {
-            max-width: 100%;
+            max-width: 92%;
+            max-height: 42mm;
             height: auto;
+            object-fit: contain;
             vertical-align: middle;
         }
         @media print {

+ 3 - 1
resources/views/pdf/partials/common-styles.blade.php

@@ -342,8 +342,10 @@
     
     /* 选项中的图片样式 */
     .option img {
-        max-width: 100%;
+        max-width: 92%;
+        max-height: 42mm;
         height: auto;
+        object-fit: contain;
         vertical-align: middle;
     }
 

+ 19 - 3
resources/views/pdf/partials/grading-scan-sheet.blade.php

@@ -3,16 +3,32 @@
     // 按当前试卷真实题目动态生成判题卡条目(题量与方框数)
     $scanSheetItems = [];
     $countBlanks = fn($text): int => $boxCounter->countFillBlanks($text);
-    $countSteps = fn($text): int => $boxCounter->countAnswerSteps($text);
+    $countAnswerBoxes = fn($stem = '', $solution = ''): int => $boxCounter->countAnswerMarkBoxes($stem, $solution);
 
     foreach (($questions['choice'] ?? []) as $q) {
         $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => 1];
     }
     foreach (($questions['fill'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countBlanks($q->content ?? '')];
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countBlanks($stemText),
+        ];
     }
     foreach (($questions['answer'] ?? []) as $q) {
-        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => $countSteps($q->solution ?? '')];
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countAnswerBoxes($stemText, $q->solution ?? ''),
+        ];
     }
 
     usort($scanSheetItems, static function ($a, $b) {

+ 127 - 0
resources/views/pdf/partials/paper-body-core-styles.blade.php

@@ -0,0 +1,127 @@
+/* 大题标题:不与后面内容分开 */
+.section-title {
+    font-size: 16px;
+    font-weight: bold;
+    margin-top: 20px;
+    margin-bottom: 10px;
+    page-break-after: avoid;
+    break-after: avoid;
+}
+/* 题目整体:不分页 */
+.question {
+    margin-bottom: 15px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+    -webkit-column-break-inside: avoid;
+}
+/* 题目网格:不分页 */
+.question-grid {
+    display: grid;
+    grid-template-columns: auto 1fr;
+    column-gap: 4px;
+    row-gap: 6px;
+    align-items: flex-start;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.question-lead {
+    display: flex;
+    gap: 4px;
+    align-items: flex-start;
+    font-weight: 600;
+    font-size: 14px;
+    line-height: 1.65;
+    margin-top: 1px;
+}
+.question-lead.spacer { visibility: hidden; }
+.question-number { white-space: nowrap; margin-right: 2px; }
+.question-main { font-size: 14px; line-height: 1.65; font-family: inherit; display: block; }
+/* 题目内容:防止孤行 */
+.question-stem {
+    display: block;
+    font-size: 14px;
+    font-family: inherit;
+    orphans: 3;
+    widows: 3;
+}
+.question-content {
+    font-size: 14px;
+    margin-bottom: 8px;
+    line-height: 1.6;
+    orphans: 3;
+    widows: 3;
+}
+.question-main {
+    orphans: 3;
+    widows: 3;
+}
+/* 选项容器:不分页 */
+.options {
+    display: grid;
+    row-gap: 8px;
+    margin-top: 8px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.options-grid-4 {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 8px 12px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.options-grid-2 {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px 20px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.options-grid-1 {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: 8px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+/* 单个选项:不分页 */
+.option {
+    width: 100%;
+    font-size: 13.2px;
+    line-height: 1.6;
+    word-wrap: break-word;
+    display: flex;
+    align-items: baseline;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+.option-value { display: inline; }
+.option-short { white-space: nowrap; }
+.option-long { white-space: normal; word-break: break-word; }
+.option-inline { display: inline-flex; align-items: baseline; margin-right: 20px; }
+.option p, .option div { margin: 0; display: inline; }
+.answer-area {
+    position: relative;
+    margin-top: 12px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.answer-area .answer-label {
+    position: absolute;
+    top: -10px;
+    left: 10px;
+    font-size: 10px;
+    background: #fff;
+    padding: 0 4px;
+    color: #555;
+    letter-spacing: 1px;
+}
+.answer-area.boxy {
+    min-height: 150px;
+    border: 1.5px solid #444;
+    border-radius: 6px;
+    padding: 14px;
+}
+.question-stem .katex, .question-main .katex, .question-content .katex { font-size: 1em !important; vertical-align: 0; }
+.question-stem .katex-display, .question-main .katex-display, .question-content .katex-display { margin: 0.35em 0 !important; }

+ 195 - 0
resources/views/pdf/partials/question-check-page.blade.php

@@ -0,0 +1,195 @@
+@php
+    $choiceQuestions = $questions['choice'] ?? [];
+    $fillQuestions = $questions['fill'] ?? [];
+    $answerQuestions = $questions['answer'] ?? [];
+    $stepPattern = '(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?|第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)';
+
+    $allQuestions = collect()
+        ->concat(collect($choiceQuestions)->map(fn ($q) => ['q' => $q, 'detail_type' => 'choice']))
+        ->concat(collect($fillQuestions)->map(fn ($q) => ['q' => $q, 'detail_type' => 'fill']))
+        ->concat(collect($answerQuestions)->map(fn ($q) => ['q' => $q, 'detail_type' => 'answer']))
+        ->sortBy(fn ($item) => (int) (($item['q']->question_number ?? 0)))
+        ->values();
+
+    $normalizeAnswerText = function (?string $answer, bool $compact = false): string {
+        $answer = trim((string) $answer);
+        if ($answer === '') {
+            return '—';
+        }
+        $answer = preg_replace('/\s+/u', ' ', $answer) ?? $answer;
+        $answer = str_replace([';', ';'], [';<wbr>', ';<wbr>'], $answer);
+        if ($compact) {
+            $answer = preg_replace('/<br\s*\/?>/iu', ' ', $answer) ?? $answer;
+        }
+
+        return trim($answer);
+    };
+
+    $quickAnswers = $allQuestions->map(function ($item) use ($normalizeAnswerText) {
+        $q = $item['q'];
+        $answerText = $normalizeAnswerText($q->answer ?? '', true);
+        $isLong = mb_strlen(strip_tags($answerText)) > 20
+            || str_contains($answerText, "\n")
+            || str_contains($answerText, "\r")
+            || str_contains($answerText, '{')
+            || str_contains($answerText, '\\frac')
+            || str_contains($answerText, '见解析');
+        $processedAnswer = \App\Services\MathFormulaProcessor::processFormulas($answerText);
+
+        return [
+            'no' => (int) ($q->question_number ?? 0),
+            'answer' => $processedAnswer,
+            'long' => $isLong,
+        ];
+    })->values();
+
+    $normalizeDetailHtml = function (?string $text): string {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return '';
+        }
+        $text = preg_replace('/<\s*image\b/iu', '<img', $text) ?? $text;
+        $text = preg_replace('/<\s*\/\s*image\s*>/iu', '', $text) ?? $text;
+        $text = preg_replace('/(^|[\s>])img\s+src\s*=\s*([\'"][^\'"]+[\'"])\s*\/?>/iu', '$1<img src=$2 />', $text) ?? $text;
+        $text = preg_replace('/font-size\s*:[^;"]+;?/iu', '', $text) ?? $text;
+        $text = preg_replace('/line-height\s*:[^;"]+;?/iu', '', $text) ?? $text;
+        $text = preg_replace('/style\s*=\s*([\'"])\s*\1/iu', '', $text) ?? $text;
+
+        return $text;
+    };
+
+    $formatDetailForReadability = function (?string $text) use ($stepPattern): string {
+        $text = trim((string) $text);
+        if ($text === '') {
+            return '';
+        }
+
+        $text = preg_replace('/\s*(' . $stepPattern . ')/u', '<br>$1', $text) ?? $text;
+        $text = str_replace([';', ';'], [';<wbr>', ';<wbr>'], $text);
+        $text = preg_replace('/,\s*(则|故|所以)/u', ',<br>$1', $text) ?? $text;
+        $text = preg_replace('/(?:<br>\s*){2,}/u', '<br>', $text) ?? $text;
+
+        return preg_replace('/^(?:\s*<br\s*\/?>\s*)+/iu', '', $text) ?? $text;
+    };
+
+    $renderSolutionLikeGrading = function (string $solutionHtml, bool $withStepBoxes = false) use ($stepPattern): string {
+        $solution = trim($solutionHtml);
+        if ($solution === '' || $solution === '(无详解)') {
+            return $solutionHtml;
+        }
+
+        $solution = preg_replace('/(\s*\d+\s*分\s*)/u', '', $solution) ?? $solution;
+        $solution = preg_replace('/【(解题思路|详细解答|最终答案)】/u', "\n\n===SECTION_START===\n【$1】\n===SECTION_END===\n\n", $solution) ?? $solution;
+        $solution = preg_replace('/(解题过程\s*[^:\n]*:)/u', "\n\n===SECTION_START===\n【解题过程】\n===SECTION_END===\n\n", $solution) ?? $solution;
+
+        $sections = explode('===SECTION_START===', $solution);
+        $processedSections = [];
+        foreach ($sections as $section) {
+            if (trim((string) $section) === '') {
+                continue;
+            }
+            $section = str_replace('===SECTION_END===', '', $section);
+
+            if (preg_match('/【(解题思路|详细解答|最终答案|解题过程)】/u', $section, $matches)) {
+                $sectionTitle = $matches[0];
+                $sectionContent = preg_replace('/【(解题思路|详细解答|最终答案|解题过程)】/u', '', $section) ?? $section;
+
+                if ($withStepBoxes) {
+                    if (preg_match('/' . $stepPattern . '/u', $sectionContent)) {
+                        $parts = preg_split('/(?=' . $stepPattern . ')/u', $sectionContent, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+                        $processed = '';
+                        foreach ($parts as $index => $part) {
+                            $stepText = trim((string) $part);
+                            if ($stepText === '') {
+                                continue;
+                            }
+                            $prefix = $index > 0 ? '<br>' : '';
+                            $isStep = preg_match('/^' . $stepPattern . '/u', $stepText);
+                            if ($isStep) {
+                                $processed .= $prefix
+                                    . '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">'
+                                    . $stepText
+                                    . '</span></span>';
+                            } else {
+                                $processed .= $prefix . '<span class="step-label">' . $stepText . '</span>';
+                            }
+                        }
+                        $sectionContent = $processed;
+                    } else {
+                        $sectionContent = '<span class="solution-step"><span class="step-box"><span class="detail-grade-box"></span></span><span class="step-label">&nbsp;</span></span> ' . trim((string) $sectionContent);
+                    }
+                }
+
+                $processedSections[] = '<div class="solution-section"><strong>' . $sectionTitle . '</strong><br>' . $sectionContent . '</div>';
+            } else {
+                $processedSections[] = $section;
+            }
+        }
+
+        $solution = implode('', $processedSections);
+        $solution = preg_replace('/\n{3,}/u', "\n\n", $solution) ?? $solution;
+
+        return nl2br($solution);
+    };
+@endphp
+
+<div class="answer-detail-page">
+    <div class="answer-quick">
+        <div class="answer-quick-label">答案速查</div>
+        <div class="answer-quick-flow answer-quick-flow-ordered">
+            @foreach($quickAnswers as $item)
+                <span class="answer-quick-item {{ $item['long'] ? 'answer-quick-item-long' : 'answer-quick-item-compact' }}">
+                    <strong>{{ $item['no'] }}.&nbsp;</strong>{!! $item['answer'] !!}
+                </span>
+            @endforeach
+        </div>
+    </div>
+
+    <div class="answer-detail-two-cols">
+        @foreach($allQuestions as $item)
+            @php
+                $q = $item['q'];
+                $no = (int) ($q->question_number ?? 0);
+                $detailType = strtolower((string) ($item['detail_type'] ?? ''));
+                $isAnswerType = $detailType === 'answer';
+                $rawAnswer = trim((string) ($q->answer ?? ''));
+                $rawSolution = $normalizeDetailHtml($q->solution ?? '');
+                $isSeeSolutionAnswer = (bool) preg_match('/^见\s*解析[。\.]?$|^详见解析/u', $rawAnswer);
+                $hasImageLikeSolution = (bool) preg_match('/<\s*img\b|<\s*image\b|(^|[\s>])img\s+src\s*=/iu', $rawSolution);
+                $showAnswer = !$isSeeSolutionAnswer;
+
+                $renderAnswer = \App\Services\MathFormulaProcessor::processFormulas($normalizeAnswerText($rawAnswer, false));
+                if ($rawSolution === '') {
+                    $renderSolution = '(无详解)';
+                } elseif ($hasImageLikeSolution) {
+                    $renderSolution = $formatDetailForReadability($rawSolution);
+                } else {
+                    $renderSolution = \App\Services\MathFormulaProcessor::processFormulas($formatDetailForReadability($rawSolution));
+                }
+                $renderSolution = $renderSolutionLikeGrading($renderSolution, $isAnswerType);
+                $useSplitAnalysisLine = $isAnswerType && $showAnswer;
+            @endphp
+            <div class="answer-detail-item">
+                @if($useSplitAnalysisLine)
+                    <div class="entry-line entry-answer-line">
+                        <span class="qno">{{ $no }}.</span>
+                        <span class="answer-only">【答案】{!! $renderAnswer !!}</span>
+                    </div>
+                    <div class="entry-line entry-analysis-line">
+                        <span class="analysis-tag">【解析】</span>
+                        <span class="solution-only">{!! $renderSolution !!}</span>
+                    </div>
+                @else
+                    <div class="entry-line entry-inline-line">
+                        <span class="qno">{{ $no }}.</span>
+                        @if($showAnswer)
+                            <span class="answer-only">【答案】{!! $renderAnswer !!}</span>
+                        @endif
+                        <span class="analysis-tag">【解析】</span>
+                        <span class="solution-only">{!! $renderSolution !!}</span>
+                    </div>
+                @endif
+            </div>
+        @endforeach
+    </div>
+</div>

+ 110 - 0
resources/views/pdf/partials/question-check-scan-sheet.blade.php

@@ -0,0 +1,110 @@
+@php
+    $boxCounter = app(\App\Support\GradingMarkBoxCounter::class);
+    $scanSheetItems = [];
+    $countBlanks = fn($text): int => $boxCounter->countFillBlanks($text);
+    $countAnswerBoxes = fn($stem = '', $solution = ''): int => $boxCounter->countAnswerMarkBoxes($stem, $solution);
+
+    foreach (($questions['choice'] ?? []) as $q) {
+        $scanSheetItems[] = ['no' => (int) ($q->question_number ?? 0), 'box_count' => 1];
+    }
+    foreach (($questions['fill'] ?? []) as $q) {
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countBlanks($stemText),
+        ];
+    }
+    foreach (($questions['answer'] ?? []) as $q) {
+        $stemText = (string) ($q->question_text ?? '');
+        if ($stemText === '') {
+            // 兼容旧链路:部分历史对象仅有 content 字段
+            $stemText = (string) ($q->content ?? '');
+        }
+        $scanSheetItems[] = [
+            'no' => (int) ($q->question_number ?? 0),
+            'box_count' => $countAnswerBoxes($stemText, $q->solution ?? ''),
+        ];
+    }
+
+    usort($scanSheetItems, static function ($a, $b) {
+        return ($a['no'] <=> $b['no']);
+    });
+    $scanSheetItems = array_values(array_filter($scanSheetItems, static function ($item) {
+        return (int) ($item['no'] ?? 0) > 0;
+    }));
+
+    $assembleTypeLabel = $pdfMeta['assemble_type_label'] ?? null;
+    $showAssembleType = !empty($assembleTypeLabel) && $assembleTypeLabel !== '未知类型';
+    $scanPaperCode = (string) ($pdfMeta['paper_id_num'] ?? $pdfMeta['exam_code'] ?? '');
+    if ($scanPaperCode === '' && !empty($paper->paper_id)) {
+        $scanPaperCode = preg_replace('/^paper_/', '', (string) $paper->paper_id) ?: (string) $paper->paper_id;
+    }
+
+    $totalItems = count($scanSheetItems);
+    $leftCount = (int) ceil($totalItems / 2);
+    $leftItems = array_slice($scanSheetItems, 0, $leftCount);
+    $rightItems = array_slice($scanSheetItems, $leftCount);
+@endphp
+
+<div class="page scan-sheet-page" style="page-break-before: auto; break-before: auto; width:100%; max-width:100%; margin:0 auto; padding:0 8px; box-sizing:border-box;">
+    <div class="scan-sheet-header" style="text-align:center;margin-bottom:1.5rem;border-bottom:2px solid #000;padding-bottom:1rem;">
+        <div style="font-size:22px;font-weight:bold;">判题卡</div>
+        @if($scanPaperCode !== '')
+            <div class="scan-sheet-paper-code" style="margin-top:6px;font-size:18px;font-weight:700;letter-spacing:0.4px;">{{ $scanPaperCode }}</div>
+        @endif
+        <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+            <span>老师:{{ $teacher['name'] ?? '________' }}</span>
+            <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+            @if($showAssembleType)
+                <span>类型:{{ $assembleTypeLabel }}</span>
+            @endif
+            <span>姓名:{{ $student['name'] ?? '________' }}</span>
+            <span>得分:________</span>
+        </div>
+    </div>
+    <div class="scan-sheet-hint" style="font-size:14px;color:#444;margin-bottom:14px;line-height:1.5;">提示:请根据答案和解析进行批改,在回答正确的 □ 内划 / ,在回答错误的 □ 内打 X 或置空</div>
+
+    <div class="scan-sheet-two-cols" style="display:flex;align-items:flex-start;gap:18px;">
+        <div class="scan-sheet-col" style="flex:1 1 0;display:grid;row-gap:12px;">
+            @foreach($leftItems as $item)
+                @php
+                    $questionNo = (int) ($item['no'] ?? 0);
+                    $boxCount = max(1, (int) ($item['box_count'] ?? 1));
+                @endphp
+                <div class="scan-sheet-item" style="display:flex;align-items:center;min-height:24px;">
+                    <span class="scan-sheet-no" style="font-weight:700;font-size:14px;line-height:1.2;white-space:nowrap;margin-right:2px;">题目 {{ $questionNo }}.</span>
+                    <span class="scan-sheet-marks" style="display:inline-flex;align-items:center;gap:0;">
+                        @for($i = 0; $i < $boxCount; $i++)
+                            <span class="scan-grade-box" style="display:inline-block;width:21px;height:21px;border:1px solid #333;box-sizing:border-box;background:#fff;vertical-align:middle;margin-right:16px;"></span>
+                        @endfor
+                    </span>
+                </div>
+            @endforeach
+        </div>
+
+        <div class="scan-sheet-col" style="flex:1 1 0;display:grid;row-gap:12px;">
+            @foreach($rightItems as $item)
+                @php
+                    $questionNo = (int) ($item['no'] ?? 0);
+                    $boxCount = max(1, (int) ($item['box_count'] ?? 1));
+                @endphp
+                <div class="scan-sheet-item" style="display:flex;align-items:center;min-height:24px;">
+                    <span class="scan-sheet-no" style="font-weight:700;font-size:14px;line-height:1.2;white-space:nowrap;margin-right:2px;">题目 {{ $questionNo }}.</span>
+                    <span class="scan-sheet-marks" style="display:inline-flex;align-items:center;gap:0;">
+                        @for($i = 0; $i < $boxCount; $i++)
+                            <span class="scan-grade-box" style="display:inline-block;width:21px;height:21px;border:1px solid #333;box-sizing:border-box;background:#fff;vertical-align:middle;margin-right:16px;"></span>
+                        @endfor
+                    </span>
+                </div>
+            @endforeach
+        </div>
+    </div>
+
+    @if($totalItems === 0)
+        <div class="scan-sheet-empty" style="color:#888;font-size:13px;margin-top:6px;">暂无可渲染题目</div>
+    @endif
+</div>

+ 90 - 0
resources/views/pdf/question-check.blade.php

@@ -0,0 +1,90 @@
+@php
+    $gradingCode = $pdfMeta['exam_code'] ?? ($paper->paper_id ?? 'unknown');
+    $studentName = $pdfMeta['student_name'] ?? ($student['name'] ?? ($paper->student_id ?? '________'));
+    $paperHeaderTitle = $pdfMeta['header_title'] ?? ($studentName . '|' . $gradingCode . '|题目质检');
+    $generateDateTime = now()->format('Y年m月d日 H:i:s');
+@endphp
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>{{ $pdfMeta['grading_pdf_title'] ?? ($paper->paper_name ?? '题目质检预览') }}</title>
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    <style>
+        @page {
+            size: A4;
+            margin: 2.2cm 2cm 2.3cm 2cm;
+            @top-left { content: "知了数学·{{ $generateDateTime }}"; font-size: 13px; color: #666; }
+            @top-center { content: "{{ $studentName }}"; font-size: 13px; color: #666; }
+            @top-right {
+                content: "{{ $gradingCode }}";
+                font-size: 19px;
+                font-weight: 600;
+                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
+                color: #222;
+            }
+            @bottom-left { content: "{{ $paperHeaderTitle }}"; font-size: 11px; color: #666; }
+            @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
+        }
+        body {
+            font-family: "Noto Serif", "Noto Serif CJK SC", "Noto Sans CJK SC", "Noto Sans", "STSongti-SC", "PingFang SC", "Songti SC", serif;
+            line-height: 1.65;
+            color: #000;
+            background: #fff;
+            font-size: 14px;
+        }
+        .page { max-width: 720px; margin: 0 auto; padding: 0 12px; }
+        .header { text-align: center; margin-bottom: 1.1rem; border-bottom: 2px solid #000; padding-bottom: 0.8rem; }
+        @include('pdf.partials.answer-detail-styles')
+        @include('pdf.partials.grading-scan-sheet-styles')
+        @include('pdf.partials.paper-body-core-styles')
+    </style>
+</head>
+<body style="page-break-before: always;">
+    <div class="page">
+        <div class="header">
+            <div style="font-size:22px;font-weight:bold;">题干区</div>
+            <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+                <span>老师:{{ $teacher['name'] ?? '________' }}</span>
+                <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+                <span>类型:题目质检</span>
+                <span>姓名:{{ $student['name'] ?? '________' }}</span>
+                <span>得分:________</span>
+            </div>
+        </div>
+        @include('components.exam.paper-body', ['questions' => $questions, 'grading' => false])
+    </div>
+
+    <div class="page">
+        @include('pdf.partials.answer-detail-page', ['questions' => $questions])
+    </div>
+
+    @include('pdf.partials.question-check-scan-sheet', [
+        'questions' => $questions,
+        'gradingCode' => $gradingCode,
+        'teacher' => $teacher,
+        'student' => $student,
+        'pdfMeta' => $pdfMeta ?? [],
+    ])
+
+    <script src="/js/katex.min.js"></script>
+    <script src="/js/auto-render.min.js"></script>
+    <script>
+        document.addEventListener('DOMContentLoaded', function() {
+            try {
+                renderMathInElement(document.body, {
+                    delimiters: [
+                        {left: '$$', right: '$$', display: true},
+                        {left: '$', right: '$', display: false},
+                        {left: '\\(', right: '\\)', display: false},
+                        {left: '\\[', right: '\\]', display: true}
+                    ],
+                    throwOnError: false,
+                    strict: false,
+                    trust: true
+                });
+            } catch (e) {}
+        });
+    </script>
+</body>
+</html>

+ 3 - 2
tests/Unit/OptionLayoutDeciderTest.php

@@ -89,10 +89,11 @@ class OptionLayoutDeciderTest extends TestCase
         $this->assertSame('options-grid-2', $result['class']);
     }
 
-    public function test_normalize_compact_fraction_for_display(): void
+    public function test_keep_standard_fraction_display_format(): void
     {
         $decider = new OptionLayoutDecider();
-        $this->assertSame('\\sqrt{3}/2', $decider->normalizeCompactMathForDisplay('\\frac{\\sqrt{3}}{2}'));
+        $this->assertSame('\\frac{\\sqrt{3}}{2}', $decider->normalizeCompactMathForDisplay('\\frac{\\sqrt{3}}{2}'));
+        $this->assertSame('-\\frac{\\sqrt{3}}{2}', $decider->normalizeCompactMathForDisplay('-\\frac{\\sqrt{3}}{2}'));
         $this->assertSame('\\frac{x+1}{2}', $decider->normalizeCompactMathForDisplay('\\frac{x+1}{2}'));
     }
 }