ソースを参照

Merge branch 'main' into feat/question-bank-qc

Made-with: Cursor
yemeishu 3 週間 前
コミット
96ab71cfa1
64 ファイル変更9397 行追加711 行削除
  1. 3 0
      .gitignore
  2. 269 0
      app/Console/Commands/AnalyzeQuestionDifficultyCalibrationCommand.php
  3. 6 0
      app/DTO/ExamAnalysisDataDto.php
  4. 6 0
      app/DTO/ReportPayloadDto.php
  5. 6 7
      app/Filament/Pages/IntelligentExamGeneration.php
  6. 196 264
      app/Http/Controllers/Api/IntelligentExamController.php
  7. 1 1
      app/Http/Controllers/Api/MistakeBookController.php
  8. 13 16
      app/Http/Controllers/Api/QuestionPdfController.php
  9. 11 1
      app/Http/Controllers/ExamPdfController.php
  10. 677 0
      app/Jobs/AssembleExamTaskJob.php
  11. 66 0
      app/Jobs/GenerateExamPdfJob.php
  12. 608 0
      app/Services/Analytics/QuestionDifficultyCalibrationAnalyzer.php
  13. 1096 0
      app/Services/Analytics/QuestionDifficultyCalibrationService.php
  14. 1 2
      app/Services/ApiDocumentation.php
  15. 438 29
      app/Services/DiagnosticChapterService.php
  16. 25 13
      app/Services/ExamAnswerAnalysisService.php
  17. 1323 47
      app/Services/ExamPdfExportService.php
  18. 31 20
      app/Services/ExamTypeStrategy.php
  19. 3 5
      app/Services/LearningAnalyticsService.php
  20. 21 4
      app/Services/MathFormulaProcessor.php
  21. 246 1
      app/Services/MistakeBookService.php
  22. 13 0
      app/Services/PaperIdGenerator.php
  23. 11 1
      app/Services/PaperPayloadService.php
  24. 23 3
      app/Services/QuestionBankService.php
  25. 123 0
      app/Services/QuestionDifficultyResolver.php
  26. 26 7
      app/Services/QuestionLocalService.php
  27. 2 2
      app/Services/TaskManager.php
  28. 326 0
      app/Support/BlankPlaceholderRenderer.php
  29. 66 12
      app/Support/GradingMarkBoxCounter.php
  30. 2 25
      app/Support/GradingStyleQuestionStem.php
  31. 1 3
      app/Support/JudgeCardTemplateBuilder.php
  32. 9 29
      app/Support/OptionLayoutDecider.php
  33. 12 1
      app/Support/PaperNaming.php
  34. 1 0
      bootstrap/app.php
  35. 3 1
      composer.json
  36. 143 0
      config/exam.php
  37. 21 0
      config/pdf.php
  38. 5 0
      config/question_bank.php
  39. 26 0
      database/migrations/2026_04_15_223000_create_pdf_image_metrics_table.php
  40. 38 0
      database/migrations/2026_04_16_120000_create_question_difficulty_calibrations_table.php
  41. 283 0
      public/mockups/cluster-focus-demo.html
  42. 54 59
      resources/views/components/exam/paper-body.blade.php
  43. 1286 0
      resources/views/exam-analysis/pdf-report-v3.blade.php
  44. 5 1
      resources/views/exam-analysis/pdf-report.blade.php
  45. 10 0
      resources/views/filament/pages/question-detail.blade.php
  46. 7 1
      resources/views/pdf/exam-grading.blade.php
  47. 3 3
      resources/views/pdf/exam-knowledge-explanation.blade.php
  48. 6 138
      resources/views/pdf/exam-paper.blade.php
  49. 10 9
      resources/views/pdf/partials/answer-detail-page.blade.php
  50. 5 0
      resources/views/pdf/partials/answer-detail-styles.blade.php
  51. 3 1
      resources/views/pdf/partials/common-styles.blade.php
  52. 19 3
      resources/views/pdf/partials/grading-scan-sheet.blade.php
  53. 161 0
      resources/views/pdf/partials/paper-body-core-styles.blade.php
  54. 68 0
      resources/views/pdf/partials/paper-exam-shared-image-styles.blade.php
  55. 195 0
      resources/views/pdf/partials/question-check-page.blade.php
  56. 110 0
      resources/views/pdf/partials/question-check-scan-sheet.blade.php
  57. 91 0
      resources/views/pdf/question-check.blade.php
  58. 88 0
      scripts/audit_question_stem_quality.php
  59. 220 0
      scripts/audit_rendered_placeholder_integrity.php
  60. 203 0
      scripts/compare_question_pdf_two_paths.php
  61. 276 0
      scripts/dump_priority_issue_pdfs_local.php
  62. 283 0
      scripts/generate_sample_placeholder_audit_pdf.php
  63. 111 0
      tests/Unit/BlankPlaceholderRendererTest.php
  64. 3 2
      tests/Unit/OptionLayoutDeciderTest.php

+ 3 - 0
.gitignore

@@ -46,6 +46,9 @@ ansible/
 scripts/vector_bold_fix/
 .serena/
 
+# Local planning/assistant workspace artifacts
+.planning/
+.claude/
 # 本地工具 / IDE(勿提交)
 /.cursor
 /.playwright-cli

+ 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);
+    }
+}

+ 6 - 0
app/DTO/ExamAnalysisDataDto.php

@@ -14,8 +14,10 @@ class ExamAnalysisDataDto
         public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $masteryMap,
         public readonly array $examHitKpCodes,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
+        public readonly array $fullParentMasteryLevels, // 新增:全量父节点掌握度
         public readonly array $insights,
         public readonly array $recommendations,
         public readonly array $rawAnalysisData = [],
@@ -33,8 +35,10 @@ class ExamAnalysisDataDto
             teacher: $data['teacher'] ?? [],
             questions: $data['questions'] ?? [],
             mastery: $data['mastery'] ?? [],
+            masteryMap: $data['mastery_map'] ?? [],
             examHitKpCodes: $data['exam_hit_kp_codes'] ?? [],
             parentMasteryLevels: $data['parent_mastery_levels'] ?? [], // 新增:父节点掌握度数据
+            fullParentMasteryLevels: $data['full_parent_mastery_levels'] ?? [],
             insights: $data['insights'] ?? [],
             recommendations: $data['recommendations'] ?? [],
             rawAnalysisData: $data['analysis_data'] ?? [],
@@ -53,8 +57,10 @@ class ExamAnalysisDataDto
             'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'mastery_map' => $this->masteryMap,
             'exam_hit_kp_codes' => $this->examHitKpCodes,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
+            'full_parent_mastery_levels' => $this->fullParentMasteryLevels,
             'insights' => $this->insights,
             'recommendations' => $this->recommendations,
             'analysis_data' => $this->rawAnalysisData,

+ 6 - 0
app/DTO/ReportPayloadDto.php

@@ -14,8 +14,10 @@ class ReportPayloadDto
         public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $masteryMap,
         public readonly array $examHitKpCodes,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
+        public readonly array $fullParentMasteryLevels, // 新增:全量父节点掌握度
         public readonly array $questionInsights,
         public readonly array $recommendations,
         public readonly array $analysisData = []
@@ -32,8 +34,10 @@ class ReportPayloadDto
             teacher: $dto->teacher,
             questions: $dto->questions,
             mastery: $dto->mastery,
+            masteryMap: $dto->masteryMap,
             examHitKpCodes: $dto->examHitKpCodes,
             parentMasteryLevels: $dto->parentMasteryLevels, // 新增:父节点掌握度数据
+            fullParentMasteryLevels: $dto->fullParentMasteryLevels,
             questionInsights: $dto->insights,
             recommendations: $dto->recommendations,
             // 必须透传原始 analysis_data,模板依赖 question_analysis/knowledge_point_analysis 原始结构
@@ -52,8 +56,10 @@ class ReportPayloadDto
             'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'mastery_map' => $this->masteryMap,
             'exam_hit_kp_codes' => $this->examHitKpCodes,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
+            'full_parent_mastery_levels' => $this->fullParentMasteryLevels,
             'question_insights' => $this->questionInsights,
             'recommendations' => $this->recommendations,
             'analysis_data' => $this->analysisData,

+ 6 - 7
app/Filament/Pages/IntelligentExamGeneration.php

@@ -34,7 +34,7 @@ class IntelligentExamGeneration extends Page
     public ?string $paperDescription = '';
     public ?string $selectedGrade = '初中'; // 初中/七年级/八年级/九年级
     public ?string $difficultyCategory = '基础'; // 基础/进阶/竞赛
-    public int $totalQuestions = 20;
+    public int $totalQuestions = 10;
     public int $totalScore = 100;
     public bool $includeAnswer = false; // 是否生成答案(默认不生成参考答案)
 
@@ -83,6 +83,8 @@ class IntelligentExamGeneration extends Page
         // 初始化用户角色检查
         $this->initializeUserRole();
 
+        $this->totalQuestions = (int) config('question_bank.default_total_questions');
+
         // 如果是老师,自动选择当前老师
         if ($this->isTeacher) {
             $teacherId = $this->getCurrentTeacherId();
@@ -561,16 +563,13 @@ class IntelligentExamGeneration extends Page
         \Illuminate\Support\Facades\Log::info('generateExam called with studentId=' . ($this->selectedStudentId ?? 'null'));
         $this->validate([
             // 'paperName' => 'required|string|max:255', // 已移除必填
-            'totalQuestions' => 'required|integer|min:6|max:100',
+            'totalQuestions' => 'required|integer|in:'.(int) config('question_bank.default_total_questions'),
             'selectedTeacherId' => 'nullable|string', // 可选老师
             'selectedStudentId' => 'nullable|string', // 可选学生
         ]);
 
-        // 确保题目数量至少6题
-        if ($this->totalQuestions < 6) {
-            \Illuminate\Support\Facades\Log::warning('题目数量少于6题,已自动调整为6题', ['original' => $this->totalQuestions]);
-            $this->totalQuestions = 6;
-        }
+        // 管理后台与 API 保持一致:题量固定为配置值(默认10题)
+        $this->totalQuestions = (int) config('question_bank.default_total_questions');
 
         if (empty($this->selectedTeacherId) || empty($this->selectedStudentId)) {
             Notification::make()

+ 196 - 264
app/Http/Controllers/Api/IntelligentExamController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\Api;
 
+use App\Jobs\AssembleExamTaskJob;
 use App\Http\Controllers\Controller;
 use App\Models\MistakeRecord;
 use App\Models\Paper;
@@ -53,11 +54,46 @@ class IntelligentExamController extends Controller
      */
     public function store(Request $request): JsonResponse
     {
-        // 优先从body获取数据,不使用query params
-        $payload = $request->json()->all();
+        $requestStartedAt = microtime(true);
+        $requestTraceId = 'exam_req_' . substr(md5(uniqid('', true)), 0, 10);
+        Log::info('IntelligentExamController: request started', [
+            'trace_id' => $requestTraceId,
+            'path' => $request->path(),
+            'method' => $request->method(),
+        ]);
+
+        // 优先从body获取数据,不使用query params(入口仍记录 query,便于对照网关是否拼错)
+        $jsonPayload = $request->json()->all();
+        $bodySource = 'json';
+        $payload = $jsonPayload;
         if (empty($payload)) {
             $payload = $request->all();
+            $bodySource = 'request_all';
+        }
+        $queryParams = $request->query();
+        $rawBody = $request->getContent();
+        $rawBodyLen = strlen($rawBody);
+        $rawBodyLog = null;
+        if ($rawBodyLen > 0) {
+            $rawCap = 65536;
+            if ($rawBodyLen <= $rawCap) {
+                $rawBodyLog = $rawBody;
+            } else {
+                $rawBodyLog = substr($rawBody, 0, $rawCap);
+            }
         }
+
+        Log::info('IntelligentExamController: 组卷API原始请求参数(入口,未经 normalizePayload)', [
+            'trace_id' => $requestTraceId,
+            'content_type' => $request->header('Content-Type'),
+            'body_source' => $bodySource,
+            'body_payload' => $payload,
+            'query_params' => $queryParams,
+            'raw_body_length' => $rawBodyLen,
+            'raw_body' => $rawBodyLog,
+            'raw_body_truncated' => $rawBodyLen > 65536,
+        ]);
+
         $normalized = $this->normalizePayload($payload);
 
         $validator = validator($normalized, [
@@ -67,7 +103,6 @@ class IntelligentExamController extends Controller
             'grade' => 'required|integer|min:1|max:12',  // 支持小学1-6、初中7-9、高中10-12
             'student_name' => 'required|string|max:50',
             'teacher_name' => 'required|string|max:50',
-            'total_questions' => 'nullable|integer|min:1|max:100',
             'difficulty_category' => 'nullable|integer|in:0,1,2,3,4',
             'kp_codes' => 'nullable|array',
             'kp_codes.*' => 'string',
@@ -82,7 +117,7 @@ class IntelligentExamController extends Controller
             'mistake_question_ids.*' => 'string',
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             // 新增:组卷类型
-            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9',
+            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             // 错题本类型专用参数
             'paper_ids' => 'nullable|array',
@@ -117,6 +152,11 @@ class IntelligentExamController extends Controller
         ]);
 
         if ($validator->fails()) {
+            Log::warning('IntelligentExamController: 组卷API参数校验失败', [
+                'trace_id' => $requestTraceId,
+                'normalized' => $normalized,
+                'errors' => $validator->errors()->toArray(),
+            ]);
             return response()->json([
                 'success' => false,
                 'message' => '参数错误',
@@ -125,12 +165,20 @@ class IntelligentExamController extends Controller
         }
 
         $data = $validator->validated();
+
         $assembleType = (int) ($data['assemble_type'] ?? 4);
-        if (in_array($assembleType, [1, 8])) {
-            $data['total_questions'] = 10;
-        } else {
-            $data['total_questions'] = $data['total_questions'] ?? 20;
+        if ($assembleType === 15 && empty($data['paper_ids'] ?? [])) {
+            return response()->json([
+                'success' => false,
+                'message' => '参数错误',
+                'errors' => ['paper_ids' => ['assemble_type 为 15(错题再练)时,paper_ids 须为非空数组,元素为题库题目 question_id,且该学生错题本中须存在对应错题记录']], 
+            ], 422);
         }
+
+        // API 固定题量:含按卷追练(5)、错题再练(15) 等,一律 default_total_questions,不使用请求题量参数
+        $data['total_questions'] = (int) config('question_bank.default_total_questions');
+        // 预分配 paper_id,保证接口语义稳定(后续异步化时也可继续同步返回)
+        $reservedPaperId = $this->questionBankService->generatePaperId();
         $this->ensureStudentTeacherRelation($data);
 
         // 【修改】使用series_id、semester_code和grade获取textbook_id
@@ -145,260 +193,71 @@ class IntelligentExamController extends Controller
             $data['kp_codes'] = [];
         }
 
-        $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
-        // 注意: difficulty_ratio 参数已废弃,使用 difficulty_category 控制难度分布
-        $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
-        $difficultyCategory = $data['difficulty_category'] ?? 1; // 直接使用数字,不转换
-        $mistakeIds = $data['mistake_ids'] ?? [];
-        $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
-        $paperIds = $data['paper_ids'] ?? [];
-        $assembleType = $data['assemble_type'] ?? 4; // 默认为通用类型(4)
-
-        try {
-            $questions = [];
-            $result = null;
-            // 【新增】初始化章节摸底和智能组卷的关键字段
-            $diagnosticChapterId = null;
-            $explanationKpCodes = null;
-
-            if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
-                $questionIds = $this->resolveMistakeQuestionIds(
-                    $data['student_id'],
-                    $mistakeIds,
-                    $mistakeQuestionIds
-                );
-
-                if (empty($questionIds)) {
-                    return response()->json([
-                        'success' => false,
-                        'message' => '未找到可用的错题题目,请检查错题ID或学生ID',
-                    ], 400);
-                }
-
-                $bankQuestions = $this->questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
-                if (empty($bankQuestions)) {
-                    return response()->json([
-                        'success' => false,
-                        'message' => '错题对应的题库题目不存在或不可用',
-                    ], 400);
-                }
-
-                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes']);
-                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
-                $paperName = $data['paper_name'] ?? ('错题复习_'.$data['student_id'].'_'.now()->format('Ymd_His'));
-            } else {
-                // 第一步:生成智能试卷(同步)
-                $params = [
-                    'student_id' => $data['student_id'],
-                    'grade' => $data['grade'] ?? null,
-                    'total_questions' => $data['total_questions'],
-                    // 【修复】教材组卷时不使用用户传入的kp_codes,只使用章节关联的知识点
-                    'kp_codes' => $assembleType == 3 ? null : ($data['kp_codes'] ?? null),
-                    'skills' => $data['skills'] ?? [],
-                    'question_type_ratio' => $questionTypeRatio,
-                    'difficulty_category' => $difficultyCategory, // 传递难度分类(数字)
-                    'assemble_type' => $assembleType, // 新版组卷类型
-                    'exam_type' => $data['exam_type'] ?? 'general', // 兼容旧版参数
-                    'paper_ids' => $paperIds, // 错题本类型专用参数
-                    'textbook_id' => $data['textbook_id'] ?? null, // 摸底和智能组卷专用
-                    'end_catalog_id' => $data['end_catalog_id'] ?? null, // 摸底专用:截止章节ID
-                    'chapter_id_list' => $data['chapter_id_list'] ?? null, // 教材组卷专用
-                    'kp_code_list' => $assembleType == 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []), // 知识点组卷专用
-                    'practice_options' => $data['practice_options'] ?? null, // 传递专项练习选项
-                    'mistake_options' => $data['mistake_options'] ?? null, // 传递错题选项
-                ];
-
-                $result = $this->learningAnalyticsService->generateIntelligentExam($params);
-
-                if (empty($result['success'])) {
-                    $errorMsg = $result['message'] ?? '智能出卷失败';
-                    Log::error('智能出卷失败', [
-                        'student_id' => $data['student_id'],
-                        'error' => $result,
-                    ]);
-
-                    // 提供更详细的错误信息
-                    if (strpos($errorMsg, '超时') !== false) {
-                        $errorMsg = '服务响应超时,请稍后重试';
-                    } elseif (strpos($errorMsg, '连接') !== false) {
-                        $errorMsg = '依赖服务连接失败,请检查服务状态';
-                    }
-
-                    return response()->json([
-                        'success' => false,
-                        'message' => $errorMsg,
-                        'details' => $result['details'] ?? null,
-                    ], 400);
-                }
-
-                if (isset($result['stats']['difficulty_category'])) {
-                    $difficultyCategory = $result['stats']['difficulty_category'];
-                }
-
-                // 【新增】提取章节摸底和智能组卷的关键字段
-                $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
-                $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
-
-                $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']);
-            }
-
-            if (empty($questions)) {
-                return response()->json([
-                    'success' => false,
-                    'message' => '未能生成有效题目,请检查知识点或题库数据',
-                ], 400);
-            }
-
-            // 错题本类型不需要限制题目数量,由错题数量决定
-            if ($assembleType === 5) {
-                // 错题本:使用所有错题,不限制数量
-                Log::info('错题本类型,使用所有错题', [
-                    'assemble_type' => $assembleType,
-                    'question_count' => count($questions),
-                ]);
-            } else {
-                // 其他类型:限制题目数量
-                $totalQuestions = min($data['total_questions'], count($questions));
-                $questions = array_slice($questions, 0, $totalQuestions);
-            }
-
-            // 每个题型内按难度升序排序(由易到难),并重排题号
-            $questions = $this->sortQuestionsWithinTypeByDifficulty($questions);
-
-            // 调整题目分值
-            if (($data['total_questions'] ?? 20) == 20) {
-                // 20题:沿用动态凑整算法,目标总分100
-                $targetTotalScore = $data['total_score'] ?? 100.0;
-                $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
-            } else {
-                // 非20题:固定题型分值(选择5、填空5、解答10)
-                $questions = $this->applyFixedScores($questions);
-                $targetTotalScore = array_sum(array_column($questions, 'score'));
-            }
-
-            // 计算总分
-            $totalScore = array_sum(array_column($questions, 'score'));
-
-            // 第二步:保存试卷到数据库(同步)
-            // 【修复】策略可能修改assembleType(如章节智能组卷检测到全部掌握后转为下一章节摸底)
-            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
-            $paperId = $this->questionBankService->saveExamToDatabase([
-                'paper_name' => $paperName,
-                'student_id' => $data['student_id'],
-                'teacher_id' => $data['teacher_id'] ?? null,
-                'assembleType' => $finalAssembleType,
-                'difficulty_category' => $difficultyCategory,
-                'total_score' => $totalScore, // 使用计算后的实际总分
-                'questions' => $questions,
-                // 【新增】章节摸底和智能组卷的关键字段
-                'diagnostic_chapter_id' => $diagnosticChapterId ?? null,
-                'explanation_kp_codes' => $explanationKpCodes ?? null,
-            ]);
-
-            if (! $paperId) {
-                return response()->json([
-                    'success' => false,
-                    'message' => '试卷保存失败',
-                ], 500);
-            }
-
-            // 第三步:创建异步任务(使用TaskManager)
-            // 注意:callback_url会在TaskManager中被提取并保存
-            $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, array_merge($data, ['paper_id' => $paperId]));
-
-            // 生成识别码
-            $codes = $this->paperPayloadService->generatePaperCodes($paperId);
-
-            // 立即返回完整的试卷数据(不等待PDF生成)
-            $paperModel = Paper::with('questions')->find($paperId);
-            $examContent = $paperModel
-                ? $this->paperPayloadService->buildExamContent($paperModel)
-                : [];
+        $taskPayload = array_merge($data, [
+            'paper_id' => $reservedPaperId,
+            'request_trace_id' => $requestTraceId,
+            'request_started_at' => now()->toISOString(),
+        ]);
 
-            $finalStats = $result['stats'] ?? [
-                'total_selected' => count($questions),
-                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
-            ];
-            if (! isset($finalStats['difficulty_category'])) {
-                $finalStats['difficulty_category'] = $difficultyCategory;
-            }
-            if (! isset($finalStats['final_distribution'])) {
-                $distributionService = app(\App\Services\DifficultyDistributionService::class);
-                $finalBuckets = $distributionService->groupQuestionsByDifficultyRange($questions, (int) $difficultyCategory);
-                $finalTotal = max(1, count($questions));
-                $finalStats['final_distribution'] = array_map(static function ($bucket) use ($finalTotal) {
-                    $count = count($bucket);
-                    return [
-                        'count' => $count,
-                        'ratio' => round(($count / $finalTotal) * 100, 2),
-                    ];
-                }, $finalBuckets);
-            }
-            if (! isset($finalStats['final_distribution_shortage'])) {
-                $distributionService = app(\App\Services\DifficultyDistributionService::class);
-                $distribution = $distributionService->calculateDistribution((int) $difficultyCategory, (int) ($data['total_questions'] ?? count($questions)));
-                $buckets = $distributionService->groupQuestionsByDifficultyRange($questions, (int) $difficultyCategory);
-                $expected = [
-                    'primary_low' => 0,
-                    'primary_medium' => 0,
-                    'primary_high' => 0,
-                    'secondary' => 0,
-                    'other' => 0,
-                ];
-                foreach ($distribution as $level => $config) {
-                    $bucketKey = $distributionService->mapDifficultyLevelToRangeKey($level, (int) $difficultyCategory);
-                    $expected[$bucketKey] += (int) ($config['count'] ?? 0);
-                }
-                $actual = array_map(static fn($bucket) => count($bucket), $buckets);
-                $finalStats['final_distribution_shortage'] = array_map(static function ($count, $bucketKey) use ($actual) {
-                    $actualCount = $actual[$bucketKey] ?? 0;
-                    return [
-                        'expected' => $count,
-                        'actual' => $actualCount,
-                        'short' => max(0, $count - $actualCount),
-                    ];
-                }, $expected, array_keys($expected));
-            }
+        Log::info('IntelligentExamController: 组卷API请求参数(校验并补全后,即将入队)', [
+            'trace_id' => $requestTraceId,
+            'assemble_type_resolved' => $assembleType,
+            'params' => $taskPayload,
+        ]);
 
-            $this->taskManager->updateTaskStatus($taskId, [
-                'stats' => $finalStats,
-            ]);
+        try {
+            // 异步优化:同步仅返回 task_id/paper_id,重型组卷逻辑下沉到队列
+            $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, $taskPayload);
 
-            // 触发后台PDF生成
-            $this->triggerPdfGeneration($taskId, $paperId);
+            dispatch(new AssembleExamTaskJob($taskId));
 
+            $codes = $this->paperPayloadService->generatePaperCodes($reservedPaperId);
             $payload = [
                 'success' => true,
-                'message' => '智能试卷创建成功PDF正在后台生成...',
+                'message' => '智能试卷任务已创建,正在后台组卷并生成PDF...',
                 'data' => [
                     'task_id' => $taskId,
-                    'paper_id' => $paperId,
+                    'paper_id' => $reservedPaperId,
                     'status' => 'processing',
-                    // 识别码
-                    'exam_code' => $codes['exam_code'],       // 试卷识别码 (1+12位)
-                    'grading_code' => $codes['grading_code'], // 判卷识别码 (2+12位)
-                    'paper_id_num' => $codes['paper_id_num'], // 12位数字ID
-                    'exam_content' => $examContent,
+                    'exam_code' => $codes['exam_code'],
+                    'grading_code' => $codes['grading_code'],
+                    'paper_id_num' => $codes['paper_id_num'],
+                    'exam_content' => [],
                     'urls' => [
-                        'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]),
-                        'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']),
-                        'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]),
+                        'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $reservedPaperId]),
+                        'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $reservedPaperId, 'answer' => 'false']),
+                        'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $reservedPaperId]),
                     ],
                     'pdfs' => [
                         'exam_paper_pdf' => null,
                         'grading_pdf' => null,
+                        'all_pdf' => null,
                     ],
-                    'stats' => $finalStats,
+                    'stats' => null,
                     'created_at' => now()->toISOString(),
                 ],
             ];
 
+            Log::info('IntelligentExamController: async task dispatched', [
+                'trace_id' => $requestTraceId,
+                'task_id' => $taskId,
+                'paper_id' => $reservedPaperId,
+                'sync_elapsed_ms_total' => (int) round((microtime(true) - $requestStartedAt) * 1000),
+            ]);
+
+            $this->taskManager->updateTaskStatus($taskId, [
+                'request_trace_id' => $requestTraceId,
+                'sync_elapsed_ms_total' => (int) round((microtime(true) - $requestStartedAt) * 1000),
+            ]);
+
             return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES);
 
         } catch (\Exception $e) {
             Log::error('Intelligent exam API failed', [
+                'trace_id' => $requestTraceId,
                 'error' => $e->getMessage(),
                 'trace' => $e->getTraceAsString(),
+                'sync_elapsed_ms_before_error' => (int) round((microtime(true) - $requestStartedAt) * 1000),
             ]);
 
             // 返回更具体的错误信息
@@ -540,11 +399,7 @@ class IntelligentExamController extends Controller
      */
     private function normalizePayload(array $payload): array
     {
-        // 处理 question_count 参数:转换为 total_questions
-        if (isset($payload['question_count']) && ! isset($payload['total_questions'])) {
-            $payload['total_questions'] = $payload['question_count'];
-            unset($payload['question_count']);
-        }
+        unset($payload['total_questions'], $payload['question_count']);
 
         // 将student_id转换为字符串(支持数字和字符串输入)
         if (isset($payload['student_id'])) {
@@ -577,7 +432,7 @@ class IntelligentExamController extends Controller
             $payload['skills'] = array_values(array_filter(array_map('trim', explode(',', $payload['skills']))));
         }
 
-        foreach (['mistake_ids', 'mistake_question_ids'] as $key) {
+        foreach (['mistake_ids', 'mistake_question_ids', 'paper_ids'] as $key) {
             if (isset($payload[$key])) {
                 if (is_string($payload[$key])) {
                     $raw = trim($payload[$key]);
@@ -587,6 +442,22 @@ class IntelligentExamController extends Controller
                 } elseif (! is_array($payload[$key])) {
                     $payload[$key] = [];
                 }
+                // JSON 常把 id 写成数字,与 validator 的 string 规则对齐,避免 422
+                if (is_array($payload[$key])) {
+                    $payload[$key] = array_values(array_filter(array_map(
+                        static function ($v) {
+                            if ($v === null || $v === '') {
+                                return null;
+                            }
+                            if (is_scalar($v)) {
+                                return (string) $v;
+                            }
+
+                            return null;
+                        },
+                        $payload[$key]
+                    ), static fn ($v) => $v !== null && $v !== ''));
+                }
             }
         }
 
@@ -884,26 +755,6 @@ class IntelligentExamController extends Controller
         };
     }
 
-    /**
-     * 非20题时使用固定题型分值
-     * 选择题:5分,填空题:5分,解答题:10分
-     */
-    private function applyFixedScores(array $questions): array
-    {
-        foreach ($questions as &$question) {
-            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
-            $question['score'] = match ($type) {
-                'choice' => 5,
-                'fill' => 5,
-                'answer' => 10,
-                default => 5,
-            };
-        }
-        unset($question);
-
-        return $questions;
-    }
-
     /**
      * 计算试卷总分并调整各题目分值,确保总分接近目标分数
      * 符合中国中学卷子标准:
@@ -1306,12 +1157,27 @@ class IntelligentExamController extends Controller
                 return (int) $textbook->id;
             }
 
-            Log::warning('未找到匹配的教材', [
+            Log::warning('未找到匹配的教材,尝试从同系列中寻找其他教材', [
                 'series_id' => $seriesId,
                 'semester_code' => $semesterCode,
                 'grade' => $grade,
             ]);
 
+            // Fallback:从同系列中按 grade 倒序、semester 倒序选一个有章节的教材
+            $fallbackTextbook = $this->findFallbackTextbookInSeries($seriesId, $grade, $semesterCode);
+            if ($fallbackTextbook) {
+                Log::info('成功从同系列中找到fallback教材', [
+                    'original_series_id' => $seriesId,
+                    'original_semester_code' => $semesterCode,
+                    'original_grade' => $grade,
+                    'fallback_textbook_id' => $fallbackTextbook->id,
+                    'fallback_grade' => $fallbackTextbook->grade,
+                    'fallback_semester' => $fallbackTextbook->semester,
+                ]);
+
+                return (int) $fallbackTextbook->id;
+            }
+
             return null;
 
         } catch (\Exception $e) {
@@ -1325,4 +1191,70 @@ class IntelligentExamController extends Controller
             return null;
         }
     }
+
+    /**
+     * 从同系列中查找一个有章节的教材
+     * 优先选择与目标最接近的教材:grade 倒序,semester 倒序
+     * 用于教材找不到时的 fallback
+     */
+    private function findFallbackTextbookInSeries(int $seriesId, ?int $targetGrade = null, ?int $targetSemester = null): ?\stdClass
+    {
+        try {
+            // 找出同系列下有章节的教材
+            // 筛选 grade <= 目标年级(确保不选择比目标更高的年级)
+            // 按 grade 降序、semester 降序排列
+            // 这样当目标教材不存在时,会选择最接近的"上一个年级/学期"的教材
+            // 例如:找 8年级下 -> 筛选 <=8年级 -> 7年级下 > 7年级上 > 6年级下 > 6年级上
+            $query = DB::connection('mysql')
+                ->table('textbooks as t')
+                ->leftJoin('textbook_catalog_nodes as c', 't.id', '=', 'c.textbook_id')
+                ->where('t.series_id', $seriesId)
+                ->where('c.node_type', 'chapter')
+                ->whereNotNull('c.id');
+
+            if ($targetGrade !== null) {
+                $query->where('t.grade', '<=', $targetGrade);
+            }
+
+            $textbooksWithChapters = $query
+                ->select('t.id', 't.grade', 't.semester')
+                ->groupBy('t.id', 't.grade', 't.semester')
+                ->orderByDesc('t.grade')
+                ->orderByDesc('t.semester')
+                ->get();
+
+            if ($textbooksWithChapters->isEmpty()) {
+                Log::warning('同系列中没有任何教材有章节', [
+                    'series_id' => $seriesId,
+                ]);
+                return null;
+            }
+
+            // 选择第一个有章节的教材(已按 grade 倒序、semester 倒序排列)
+            $selected = $textbooksWithChapters->first();
+
+            Log::info('按倒序选择fallback教材', [
+                'series_id' => $seriesId,
+                'target_grade' => $targetGrade,
+                'target_semester' => $targetSemester,
+                'selected_textbook_id' => $selected->id,
+                'selected_grade' => $selected->grade,
+                'selected_semester' => $selected->semester,
+                'available_count' => $textbooksWithChapters->count(),
+            ]);
+
+            // 返回完整的教材对象
+            return DB::connection('mysql')
+                ->table('textbooks')
+                ->where('id', $selected->id)
+                ->first();
+
+        } catch (\Exception $e) {
+            Log::error('查找fallback教材失败', [
+                'series_id' => $seriesId,
+                'error' => $e->getMessage(),
+            ]);
+            return null;
+        }
+    }
 }

+ 1 - 1
app/Http/Controllers/Api/MistakeBookController.php

@@ -42,7 +42,7 @@ class MistakeBookController extends Controller
             // 设置默认值
             $params['page'] = (int) ($params['page'] ?? 1);
             $params['per_page'] = (int) ($params['per_page'] ?? 20);
-            $params['sort_by'] = $params['sort_by'] ?? 'created_at_desc';
+            $params['sort_by'] = $params['sort_by'] ?? 'priority_default';
 
             // 调用服务层获取错题列表
             $result = $this->mistakeBookService->listMistakes($params);

+ 13 - 16
app/Http/Controllers/Api/QuestionPdfController.php

@@ -31,7 +31,8 @@ class QuestionPdfController extends Controller
      *   "student_grade": "初二",       // optional
      *   "teacher_name": "李老师",      // optional
      *   "paper_name": "专项练习",      // optional
-     *   "include_grading": false       // optional, whether to generate grading PDF
+     *   "include_grading": false,      // optional, whether to generate grading PDF
+     *   "source": "main"             // optional: default=questions_tem;main=questions(正式题库)
      * }
      */
     public function generate(Request $request)
@@ -39,7 +40,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 +58,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 +98,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 +117,7 @@ class QuestionPdfController extends Controller
 
             return response()->json([
                 'success' => true,
-                'message' => 'PDF生成成功',
+                'message' => '题目质检PDF生成成功',
                 'data' => $result,
             ]);
 
@@ -242,7 +239,7 @@ class QuestionPdfController extends Controller
         }
 
         // Generate unique paper ID
-        $paperId = 'custom_' . $studentId . '_' . time() . '_' . uniqid();
+        $paperId = $studentId . '_' . time() . '_' . uniqid();
 
         return (object) [
             'paper_id' => $paperId,

+ 11 - 1
app/Http/Controllers/ExamPdfController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Jobs\RegeneratePdfJob;
 use App\Models\Paper;
 use App\Support\AnswerSolutionStepMarkerInjector;
+use App\Services\PaperIdGenerator;
 use App\Support\PaperNaming;
 use App\Services\QuestionBankService;
 use Illuminate\Http\Request;
@@ -501,7 +502,7 @@ class ExamPdfController extends Controller
             'header_title' => $headerTitle,
             'exam_pdf_title' => "试卷_{$headerTitle}",
             'grading_pdf_title' => "判卷_{$headerTitle}",
-            'knowledge_pdf_title' => "知识点讲解_{$headerTitle}",
+            'knowledge_pdf_title' => "知识点梳理_{$headerTitle}",
         ];
     }
 
@@ -1214,6 +1215,9 @@ class ExamPdfController extends Controller
                 'paper_id' => $paper_id,
             ], 400);
         }
+        if (! PaperIdGenerator::validatePaperId((string) $paper_id)) {
+            Log::warning('RegeneratePdf: 非标准15位 paper_id', ['paper_id' => $paper_id]);
+        }
 
         try {
             // 【修复】首先检查试卷是否存在
@@ -1301,6 +1305,9 @@ class ExamPdfController extends Controller
                 'paper_id' => $paper_id,
             ], 400);
         }
+        if (! PaperIdGenerator::validatePaperId((string) $paper_id)) {
+            Log::warning('RegenerateExamPdf: 非标准15位 paper_id', ['paper_id' => $paper_id]);
+        }
 
         try {
             $pdfService = app(\App\Services\ExamPdfExportService::class);
@@ -1352,6 +1359,9 @@ class ExamPdfController extends Controller
                 'paper_id' => $paper_id,
             ], 400);
         }
+        if (! PaperIdGenerator::validatePaperId((string) $paper_id)) {
+            Log::warning('RegenerateGradingPdf: 非标准15位 paper_id', ['paper_id' => $paper_id]);
+        }
 
         try {
             $pdfService = app(\App\Services\ExamPdfExportService::class);

+ 677 - 0
app/Jobs/AssembleExamTaskJob.php

@@ -0,0 +1,677 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\MistakeRecord;
+use App\Services\LearningAnalyticsService;
+use App\Services\QuestionBankService;
+use App\Services\TaskManager;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class AssembleExamTaskJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public string $taskId;
+
+    public int $tries = 2;
+
+    public int $timeout = 180;
+
+    public function __construct(string $taskId)
+    {
+        $this->taskId = $taskId;
+        // 复用现有 pdf 队列,与历史部署/消费者一致
+        $this->onQueue('pdf');
+        $this->afterCommit();
+    }
+
+    public function handle(
+        LearningAnalyticsService $learningAnalyticsService,
+        QuestionBankService $questionBankService,
+        TaskManager $taskManager
+    ): void {
+        $task = $taskManager->getTaskStatus($this->taskId);
+        if (!is_array($task) || empty($task['data']) || !is_array($task['data'])) {
+            $taskManager->markTaskFailed($this->taskId, '任务数据不存在');
+            return;
+        }
+
+        $data = $task['data'];
+        $assembleStartedAt = microtime(true);
+
+        try {
+            $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...');
+
+            $assembleType = (int) ($data['assemble_type'] ?? 4);
+            $difficultyCategory = $data['difficulty_category'] ?? 1;
+            $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
+            $mistakeIds = $data['mistake_ids'] ?? [];
+            $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
+            $paperIds = $data['paper_ids'] ?? [];
+            $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
+
+            $questions = [];
+            $result = null;
+            $diagnosticChapterId = null;
+            $explanationKpCodes = null;
+
+            if ($assembleType === 15) {
+                // assemble_type=15(展示类型「错题再练」):paper_ids 为题库 question_id,须在该学生 mistake_records 中存在;与 assemble_type=5(卷 id 追练)分离
+                $questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
+                if ($questionIdList === []) {
+                    $taskManager->markTaskFailed($this->taskId, '错题再练组卷需提供 paper_ids(题库题目 id)');
+                    return;
+                }
+
+                $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
+                    (string) $data['student_id'],
+                    [],
+                    array_map(static fn ($id) => (string) $id, $questionIdList)
+                );
+                if (! ($strict['ok'] ?? false)) {
+                    $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
+                    return;
+                }
+                $questionIds = $strict['question_ids'];
+
+                $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
+                if (empty($bankQuestions)) {
+                    $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
+                    return;
+                }
+
+                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
+                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
+                $paperName = $data['paper_name'] ?? ('错题再练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+            } elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
+                // assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
+                if ($assembleType === 5) {
+                    $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
+                        (string) $data['student_id'],
+                        $mistakeIds,
+                        $mistakeQuestionIds
+                    );
+                    if (! ($strict['ok'] ?? false)) {
+                        $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
+                        return;
+                    }
+                    $questionIds = $strict['question_ids'];
+                } else {
+                    $questionIds = $this->resolveMistakeQuestionIds((string) $data['student_id'], $mistakeIds, $mistakeQuestionIds);
+                }
+
+                if (empty($questionIds)) {
+                    $taskManager->markTaskFailed($this->taskId, '未找到可用的错题题目');
+                    return;
+                }
+
+                $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
+                if (empty($bankQuestions)) {
+                    $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
+                    return;
+                }
+
+                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
+                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
+                $paperName = $data['paper_name'] ?? ('错题复习_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+            } else {
+                $params = [
+                    'student_id' => $data['student_id'],
+                    'grade' => $data['grade'] ?? null,
+                    'total_questions' => $data['total_questions'],
+                    'kp_codes' => $assembleType === 3 ? null : ($data['kp_codes'] ?? null),
+                    'skills' => $data['skills'] ?? [],
+                    'question_type_ratio' => $questionTypeRatio,
+                    'difficulty_category' => $difficultyCategory,
+                    'assemble_type' => $assembleType,
+                    'exam_type' => $data['exam_type'] ?? 'general',
+                    'paper_ids' => $paperIds,
+                    'textbook_id' => $data['textbook_id'] ?? null,
+                    'end_catalog_id' => $data['end_catalog_id'] ?? null,
+                    'chapter_id_list' => $data['chapter_id_list'] ?? null,
+                    'kp_code_list' => $assembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
+                    'practice_options' => $data['practice_options'] ?? null,
+                    'mistake_options' => $data['mistake_options'] ?? null,
+                ];
+
+                $result = $learningAnalyticsService->generateIntelligentExam($params);
+                if (empty($result['success'])) {
+                    $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '智能出卷失败');
+                    return;
+                }
+
+                if (isset($result['stats']['difficulty_category'])) {
+                    $difficultyCategory = $result['stats']['difficulty_category'];
+                }
+                $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
+                $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
+                $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes'] ?? []);
+            }
+
+            if (empty($questions)) {
+                $taskManager->markTaskFailed($this->taskId, '未能生成有效题目');
+                return;
+            }
+
+            $totalQuestions = min((int) ($data['total_questions'] ?? 10), count($questions));
+            $questions = array_slice($questions, 0, $totalQuestions);
+            $questions = $this->sortQuestionsWithinTypeByDifficulty($questions);
+            $targetTotalScore = (float) ($data['total_score'] ?? 100.0);
+            $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
+            $totalScore = array_sum(array_column($questions, 'score'));
+
+            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
+            $paperId = $questionBankService->saveExamToDatabase([
+                'paper_id' => $data['paper_id'] ?? null,
+                'paper_name' => $paperName,
+                'student_id' => $data['student_id'],
+                'teacher_id' => $data['teacher_id'] ?? null,
+                'assembleType' => $finalAssembleType,
+                'difficulty_category' => $difficultyCategory,
+                'total_score' => $totalScore,
+                'questions' => $questions,
+                'diagnostic_chapter_id' => $diagnosticChapterId,
+                'explanation_kp_codes' => $explanationKpCodes,
+            ]);
+
+            if (! $paperId) {
+                $taskManager->markTaskFailed($this->taskId, '试卷保存失败');
+                return;
+            }
+
+            $finalStats = $result['stats'] ?? [
+                'total_selected' => count($questions),
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || $assembleType === 15,
+            ];
+            if (! isset($finalStats['difficulty_category'])) {
+                $finalStats['difficulty_category'] = $difficultyCategory;
+            }
+
+            $taskManager->updateTaskStatus($this->taskId, [
+                'paper_id' => $paperId,
+                'stats' => $finalStats,
+                'assemble_elapsed_ms' => (int) round((microtime(true) - $assembleStartedAt) * 1000),
+            ]);
+            $taskManager->updateTaskProgress($this->taskId, 40, '组卷完成,开始生成PDF...');
+
+            dispatch(new GenerateExamPdfJob($this->taskId, $paperId));
+            Log::info('AssembleExamTaskJob: 组卷任务完成并已触发PDF任务', [
+                'task_id' => $this->taskId,
+                'paper_id' => $paperId,
+            ]);
+        } catch (\Exception $e) {
+            Log::error('AssembleExamTaskJob: 异常', [
+                'task_id' => $this->taskId,
+                'error' => $e->getMessage(),
+            ]);
+            $taskManager->markTaskFailed($this->taskId, $e->getMessage());
+        }
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage());
+    }
+
+    private function normalizeQuestionTypeRatio(array $input): array
+    {
+        $defaults = ['选择题' => 40, '填空题' => 20, '解答题' => 40];
+        $normalized = [];
+        foreach ($input as $key => $value) {
+            if (! is_numeric($value)) {
+                continue;
+            }
+            $type = $this->normalizeQuestionTypeKey((string) $key);
+            if ($type) {
+                $normalized[$type] = (float) $value;
+            }
+        }
+        $merged = array_merge($defaults, $normalized);
+        $sum = array_sum($merged);
+        if ($sum > 0) {
+            foreach ($merged as $k => $v) {
+                $merged[$k] = round(($v / $sum) * 100, 2);
+            }
+        }
+        return $merged;
+    }
+
+    private function normalizeQuestionTypeKey(string $key): ?string
+    {
+        $key = trim($key);
+        if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice', 'CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
+            return '选择题';
+        }
+        if (in_array($key, ['fill', '填空题', 'blank', 'FILL_IN_THE_BLANK', 'FILL'], true)) {
+            return '填空题';
+        }
+        if (in_array($key, ['answer', '解答题', '计算题', 'CALCULATION', 'WORD_PROBLEM', 'PROOF'], true)) {
+            return '解答题';
+        }
+        return null;
+    }
+
+    private function resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
+    {
+        $questionIds = [];
+        if (! empty($mistakeQuestionIds)) {
+            $questionIds = array_merge($questionIds, $mistakeQuestionIds);
+        }
+        if (! empty($mistakeIds)) {
+            $fromDb = MistakeRecord::query()->where('student_id', $studentId)->whereIn('id', $mistakeIds)->pluck('question_id')->filter()->values()->all();
+            $questionIds = array_merge($questionIds, $fromDb);
+        }
+        return array_values(array_unique(array_filter($questionIds)));
+    }
+
+    /**
+     * 追练(assemble_type=5)+ 指定错题:mistake_ids 须逐条命中该学生的 mistake_records;
+     * mistake_question_ids 须在该学生错题本中至少有一条记录。顺序:先按 mistake_ids 请求顺序,再追加题号列表(去重)。
+     * assemble_type=15(错题再练)将 paper_ids 解析为题库题目 id 后,仅使用本方法的 mistake_question_ids 分支做校验。
+     *
+     * @return array{ok: bool, message?: string, question_ids?: array<int, string>}
+     */
+    private function resolveMistakeQuestionIdsStrictForStudent(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
+    {
+        $mistakeIds = array_values(array_filter(array_map('strval', $mistakeIds), fn ($v) => $v !== ''));
+        $mistakeQuestionIds = array_values(array_filter(array_map('strval', $mistakeQuestionIds), fn ($v) => $v !== ''));
+
+        $orderedQuestionIds = [];
+        $seen = [];
+
+        if ($mistakeIds !== []) {
+            $rowIdSet = array_values(array_unique($mistakeIds));
+            $records = MistakeRecord::query()
+                ->where('student_id', $studentId)
+                ->whereIn('id', $rowIdSet)
+                ->get()
+                ->keyBy(fn ($r) => (string) $r->id);
+
+            foreach ($mistakeIds as $mid) {
+                $rec = $records[$mid] ?? null;
+                $qid = $rec && $rec->question_id !== null && $rec->question_id !== ''
+                    ? (string) $rec->question_id
+                    : '';
+                if ($qid === '') {
+                    return [
+                        'ok' => false,
+                        'message' => '部分错题记录不存在或不属于该学生: '.$mid,
+                    ];
+                }
+                if (! isset($seen[$qid])) {
+                    $seen[$qid] = true;
+                    $orderedQuestionIds[] = $qid;
+                }
+            }
+        }
+
+        foreach ($mistakeQuestionIds as $qid) {
+            $exists = MistakeRecord::query()
+                ->where('student_id', $studentId)
+                ->where('question_id', $qid)
+                ->exists();
+            if (! $exists) {
+                return [
+                    'ok' => false,
+                    'message' => '学生错题本中不存在题目: '.$qid,
+                ];
+            }
+            if (! isset($seen[$qid])) {
+                $seen[$qid] = true;
+                $orderedQuestionIds[] = $qid;
+            }
+        }
+
+        return ['ok' => true, 'question_ids' => $orderedQuestionIds];
+    }
+
+    /**
+     * assemble_type=15 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
+     *
+     * @return array<int, int|string>
+     */
+    private function normalizeBankQuestionIdsList(array $raw): array
+    {
+        $out = [];
+        $seen = [];
+        foreach ($raw as $v) {
+            if ($v === null) {
+                continue;
+            }
+            if (is_string($v)) {
+                $v = trim($v);
+                if ($v === '') {
+                    continue;
+                }
+            }
+            if (is_int($v)) {
+                $normalized = $v;
+            } elseif (is_float($v) && floor($v) == $v) {
+                $normalized = (int) $v;
+            } else {
+                $s = trim((string) $v);
+                if ($s === '') {
+                    continue;
+                }
+                $normalized = preg_match('/^-?\d+$/', $s) ? (int) $s : $s;
+            }
+            $dedupeKey = is_int($normalized) ? 'i:'.$normalized : 's:'.(string) $normalized;
+            if (isset($seen[$dedupeKey])) {
+                continue;
+            }
+            $seen[$dedupeKey] = true;
+            $out[] = $normalized;
+        }
+
+        return $out;
+    }
+
+    private function hydrateQuestions(array $questions, array $kpCodes): array
+    {
+        $normalized = [];
+        foreach ($questions as $question) {
+            $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
+            $score = $question['score'] ?? $this->defaultScore($type);
+            $normalized[] = [
+                'id' => $question['id'] ?? $question['question_id'] ?? null,
+                'question_id' => $question['question_id'] ?? null,
+                'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
+                'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
+                'content' => $question['content'] ?? $question['stem'] ?? '',
+                'options' => $question['options'] ?? ($question['choices'] ?? []),
+                'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
+                'solution' => $question['solution'] ?? '',
+                'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
+                'score' => $score,
+                'estimated_time' => $question['estimated_time'] ?? 300,
+                'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
+                'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
+            ];
+        }
+        return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
+    }
+
+    private function sortQuestionsByRequestedIds(array $questions, array $requestedIds): array
+    {
+        if (empty($requestedIds)) {
+            return $questions;
+        }
+        $order = array_flip($requestedIds);
+        usort($questions, function ($a, $b) use ($order) {
+            $aPos = $order[(string) ($a['id'] ?? '')] ?? PHP_INT_MAX;
+            $bPos = $order[(string) ($b['id'] ?? '')] ?? PHP_INT_MAX;
+            return $aPos <=> $bPos;
+        });
+        return $questions;
+    }
+
+    private function guessType(array $question): string
+    {
+        if (! empty($question['options']) && is_array($question['options'])) {
+            return '选择题';
+        }
+        $content = $question['stem'] ?? $question['content'] ?? '';
+        if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
+            return '填空题';
+        }
+        return '解答题';
+    }
+
+    private function defaultScore(string $type): int
+    {
+        return match ($type) {
+            '选择题' => 5,
+            '填空题' => 5,
+            '解答题' => 10,
+            default => 5,
+        };
+    }
+
+    private function sortQuestionsWithinTypeByDifficulty(array $questions): array
+    {
+        $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($questions as $question) {
+            $type = $this->normalizeQuestionType((string) ($question['question_type'] ?? 'answer'));
+            $grouped[$type][] = $question;
+        }
+        $sortFn = function (array $a, array $b): int {
+            $ad = (float) ($a['difficulty'] ?? 0.5);
+            $bd = (float) ($b['difficulty'] ?? 0.5);
+            if ($ad !== $bd) {
+                return $ad <=> $bd;
+            }
+            return ((int) ($a['id'] ?? $a['question_id'] ?? 0)) <=> ((int) ($b['id'] ?? $b['question_id'] ?? 0));
+        };
+        usort($grouped['choice'], $sortFn);
+        usort($grouped['fill'], $sortFn);
+        usort($grouped['answer'], $sortFn);
+        $sorted = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
+        foreach ($sorted as $idx => &$question) {
+            $question['question_number'] = $idx + 1;
+        }
+        unset($question);
+        return $sorted;
+    }
+
+    private function normalizeQuestionType(string $type): string
+    {
+        $type = strtolower(trim($type));
+        if (in_array($type, ['choice', 'single_choice', 'multiple_choice', '选择题', '单选', '多选'], true)) {
+            return 'choice';
+        }
+        if (in_array($type, ['fill', 'fill_in_the_blank', 'blank', '填空题', '填空'], true)) {
+            return 'fill';
+        }
+        return 'answer';
+    }
+
+    private function adjustQuestionScores(array $questions, float $targetTotalScore = 100.0): array
+    {
+        if (empty($questions)) {
+            return $questions;
+        }
+
+        // 第一步:按题型排序
+        $sortedQuestions = [];
+        $choiceQuestions = [];
+        $fillQuestions = [];
+        $answerQuestions = [];
+
+        foreach ($questions as $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            if ($type === 'choice') {
+                $choiceQuestions[] = $question;
+            } elseif ($type === 'fill') {
+                $fillQuestions[] = $question;
+            } else {
+                $answerQuestions[] = $question;
+            }
+        }
+
+        $sortedQuestions = array_merge($choiceQuestions, $fillQuestions, $answerQuestions);
+
+        Log::debug('adjustQuestionScores 开始', [
+            'choice_count' => count($choiceQuestions),
+            'fill_count' => count($fillQuestions),
+            'answer_count' => count($answerQuestions),
+        ]);
+
+        foreach ($sortedQuestions as $idx => &$question) {
+            $question['question_number'] = $idx + 1;
+        }
+        unset($question);
+
+        $typeCounts = [
+            'choice' => count($choiceQuestions),
+            'fill' => count($fillQuestions),
+            'answer' => count($answerQuestions),
+        ];
+
+        $typeIndexes = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($sortedQuestions as $index => $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            $typeIndexes[$type][] = $index;
+        }
+
+        $questionScores = [];
+        $totalQuestions = $typeCounts['choice'] + $typeCounts['fill'] + $typeCounts['answer'];
+        $globalBaseScore = floor($targetTotalScore / $totalQuestions);
+        $globalBaseScore = max(1, $globalBaseScore);
+
+        $typeOrder = [];
+        foreach ($sortedQuestions as $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            if (! in_array($type, $typeOrder)) {
+                $typeOrder[] = $type;
+            }
+        }
+
+        $remainingBudget = $targetTotalScore;
+        foreach ($typeOrder as $typeIndex => $type) {
+            $count = $typeCounts[$type];
+            if ($count === 0) {
+                continue;
+            }
+
+            if ($typeIndex === 0) {
+                $thisBase = $globalBaseScore;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = max(1, $questionScores[$idx] - 1);
+                }
+                $allocated = 0;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $allocated += $questionScores[$idx];
+                }
+                $remainingBudget -= $allocated;
+            } elseif ($typeIndex === count($typeOrder) - 1) {
+                $thisBase = floor($remainingBudget / $count);
+                $thisBase = max(1, $thisBase);
+
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+
+                $total = $thisBase * $count;
+                $remainder = $remainingBudget - $total;
+                if ($remainder > 0) {
+                    $answerIndexes = array_values($typeIndexes[$type]);
+                    $startIdx = max(0, count($answerIndexes) - $remainder);
+                    for ($i = $startIdx; $i < count($answerIndexes); $i++) {
+                        $questionScores[$answerIndexes[$i]] += 1;
+                    }
+                }
+            } else {
+                $thisBase = $globalBaseScore;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+                $allocated = 0;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $allocated += $questionScores[$idx];
+                }
+                $remainingBudget -= $allocated;
+            }
+        }
+
+        if (count($typeOrder) > 1) {
+            $lastType = end($typeOrder);
+            $otherTypes = array_slice($typeOrder, 0, -1);
+            $maxOtherScore = 0;
+            foreach ($otherTypes as $type) {
+                foreach ($typeIndexes[$type] as $idx) {
+                    $maxOtherScore = max($maxOtherScore, $questionScores[$idx]);
+                }
+            }
+
+            $minLastScore = PHP_INT_MAX;
+            foreach ($typeIndexes[$lastType] as $idx) {
+                $minLastScore = min($minLastScore, $questionScores[$idx]);
+            }
+
+            if ($minLastScore <= $maxOtherScore) {
+                $diff = $maxOtherScore - $minLastScore + 1;
+                $reductionPerQuestion = min($diff, 2);
+                foreach ($otherTypes as $type) {
+                    foreach ($typeIndexes[$type] as $idx) {
+                        $questionScores[$idx] = max(1, $questionScores[$idx] - $reductionPerQuestion);
+                    }
+                }
+
+                $reallocated = $targetTotalScore;
+                foreach ($typeIndexes[$lastType] as $idx) {
+                    $reallocated -= $questionScores[$idx];
+                }
+                foreach ($otherTypes as $type) {
+                    foreach ($typeIndexes[$type] as $idx) {
+                        $reallocated -= $questionScores[$idx];
+                    }
+                }
+
+                if ($reallocated > 0) {
+                    $newBase = floor($reallocated / $typeCounts[$lastType]);
+                    foreach ($typeIndexes[$lastType] as $idx) {
+                        $questionScores[$idx] = $newBase;
+                    }
+
+                    $total = $newBase * $typeCounts[$lastType];
+                    $remainder = $reallocated - $total;
+                    if ($remainder > 0) {
+                        $lastIndexes = array_values($typeIndexes[$lastType]);
+                        $startIdx = max(0, count($lastIndexes) - $remainder);
+                        for ($i = $startIdx; $i < count($lastIndexes); $i++) {
+                            $questionScores[$lastIndexes[$i]] += 1;
+                        }
+                    }
+                }
+            }
+        }
+
+        $adjustedQuestions = [];
+        foreach ($sortedQuestions as $index => $question) {
+            $adjustedQuestions[$index] = $question;
+            $adjustedQuestions[$index]['score'] = $questionScores[$index] ?? 5;
+        }
+
+        $total = array_sum(array_column($adjustedQuestions, 'score'));
+        $diff = (int) $targetTotalScore - (int) $total;
+        if ($diff !== 0 && ! empty($adjustedQuestions)) {
+            $count = count($adjustedQuestions);
+            $i = $count - 1;
+            while ($diff !== 0) {
+                $score = $adjustedQuestions[$i]['score'];
+                if ($diff > 0) {
+                    $adjustedQuestions[$i]['score'] = $score + 1;
+                    $diff--;
+                } else {
+                    if ($score > 1) {
+                        $adjustedQuestions[$i]['score'] = $score - 1;
+                        $diff++;
+                    }
+                }
+
+                $i--;
+                if ($i < 0) {
+                    $i = $count - 1;
+                    if ($diff < 0) {
+                        $minScore = min(array_column($adjustedQuestions, 'score'));
+                        if ($minScore <= 1) {
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        return $adjustedQuestions;
+    }
+}

+ 66 - 0
app/Jobs/GenerateExamPdfJob.php

@@ -44,11 +44,32 @@ class GenerateExamPdfJob implements ShouldQueue
         PaperPayloadService $paperPayloadService,
         TaskManager $taskManager
     ): void {
+        $jobStartedAt = microtime(true);
+        $taskCreatedAt = null;
+        $queueWaitMs = null;
+
+        $taskSnapshot = $taskManager->getTaskStatus($this->taskId);
+        if (is_array($taskSnapshot) && !empty($taskSnapshot['created_at'])) {
+            try {
+                $taskCreatedAt = \Illuminate\Support\Carbon::parse($taskSnapshot['created_at']);
+                $queueWaitMs = $taskCreatedAt
+                    ? now()->diffInMilliseconds($taskCreatedAt, false) * -1
+                    : null;
+            } catch (\Throwable $e) {
+                $taskCreatedAt = null;
+                $queueWaitMs = null;
+            }
+        }
+
         try {
             Log::info('开始处理PDF生成队列任务', [
                 'task_id' => $this->taskId,
                 'paper_id' => $this->paperId,
                 'attempt' => $this->attempts(),
+                'queue_wait_ms' => $queueWaitMs,
+            ]);
+            $taskManager->updateTaskStatus($this->taskId, [
+                'queue_wait_ms' => $queueWaitMs,
             ]);
 
             // 【修复】首先检查试卷是否存在
@@ -136,23 +157,68 @@ class GenerateExamPdfJob implements ShouldQueue
                 ],
             ]);
 
+            $beforeCallbackAt = microtime(true);
+            $queueProcessingMs = (int) round((microtime(true) - $jobStartedAt) * 1000);
+            $taskTotalMs = null;
+            if ($taskCreatedAt) {
+                $taskTotalMs = $taskCreatedAt->diffInMilliseconds(now());
+            }
+
             Log::info('PDF生成队列任务完成(终极优化:直接合并HTML生成一份完整PDF)', [
                 'task_id' => $this->taskId,
                 'paper_id' => $this->paperId,
                 'all_pdf_url' => $unifiedPdfUrl,
                 'question_count' => $paperModel->questions->count(),
                 'method' => 'generateUnifiedPdf (direct merge, fastest)',
+                'queue_processing_ms' => $queueProcessingMs,
+                'task_total_ms_before_callback' => $taskTotalMs,
+            ]);
+            $taskManager->updateTaskStatus($this->taskId, [
+                'queue_processing_ms' => $queueProcessingMs,
+                'task_total_ms_before_callback' => $taskTotalMs,
             ]);
 
             // 发送回调通知(在合并PDF完成后)
             $taskManager->sendCallback($this->taskId);
 
+            $callbackMs = (int) round((microtime(true) - $beforeCallbackAt) * 1000);
+            $taskTotalAfterCallbackMs = null;
+            if ($taskCreatedAt) {
+                $taskTotalAfterCallbackMs = $taskCreatedAt->diffInMilliseconds(now());
+            }
+            Log::info('PDF生成队列任务回调完成', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'callback_elapsed_ms' => $callbackMs,
+                'task_total_ms_after_callback' => $taskTotalAfterCallbackMs,
+            ]);
+            $taskManager->updateTaskStatus($this->taskId, [
+                'callback_elapsed_ms' => $callbackMs,
+                'task_total_ms_after_callback' => $taskTotalAfterCallbackMs,
+            ]);
+
+            $taskSummary = $taskManager->getTaskStatus($this->taskId);
+            Log::info('EXAM_TASK_TIMELINE_SUMMARY', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'request_trace_id' => $taskSummary['request_trace_id'] ?? null,
+                'sync_elapsed_sec_total' => isset($taskSummary['sync_elapsed_ms_total'])
+                    ? round(((float) $taskSummary['sync_elapsed_ms_total']) / 1000, 3)
+                    : null,
+                'queue_wait_sec' => round(((float) ($taskSummary['queue_wait_ms'] ?? $queueWaitMs ?? 0)) / 1000, 3),
+                'queue_processing_sec' => round(((float) ($taskSummary['queue_processing_ms'] ?? $queueProcessingMs ?? 0)) / 1000, 3),
+                'callback_sec' => round(((float) ($taskSummary['callback_elapsed_ms'] ?? $callbackMs ?? 0)) / 1000, 3),
+                'task_total_sec_after_callback' => round(((float) ($taskSummary['task_total_ms_after_callback'] ?? $taskTotalAfterCallbackMs ?? 0)) / 1000, 3),
+                'status' => $taskSummary['status'] ?? null,
+            ]);
+
         } catch (\Exception $e) {
             Log::error('PDF生成队列任务失败', [
                 'task_id' => $this->taskId,
                 'paper_id' => $this->paperId,
                 'error' => $e->getMessage(),
                 'trace' => $e->getTraceAsString(),
+                'queue_elapsed_ms_before_error' => (int) round((microtime(true) - $jobStartedAt) * 1000),
             ]);
 
             // 如果是第一次失败且试卷可能还在创建中,等待后重试

+ 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;
+    }
+}

+ 1 - 2
app/Services/ApiDocumentation.php

@@ -267,7 +267,7 @@ class ApiDocumentation
             'api/intelligent-exams' => [
                 'POST' => [
                     'summary' => '智能出卷',
-                    'description' => '根据学生情况智能生成个性化试卷,返回 PDF 和判卷地址',
+                    'description' => '根据学生情况智能生成个性化试卷,返回 PDF 和判卷地址(题量固定为系统配置,默认10题)',
                     'params' => [
                         'body' => [
                             ['name' => 'student_id', 'type' => 'integer', 'required' => true, 'description' => '学生 ID(数字)'],
@@ -277,7 +277,6 @@ class ApiDocumentation
                             ['name' => 'grade', 'type' => 'string', 'required' => true, 'description' => '年级(7/8/9)'],
                             ['name' => 'knowledge_points', 'type' => 'array', 'required' => false, 'description' => '指定知识点列表'],
                             ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度偏好:easy/medium/hard/adaptive'],
-                            ['name' => 'question_count', 'type' => 'integer', 'required' => false, 'default' => '20', 'description' => '题目数量'],
                         ],
                     ],
                     'response' => [

+ 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)) {

ファイルの差分が大きいため隠しています
+ 1323 - 47
app/Services/ExamPdfExportService.php


+ 31 - 20
app/Services/ExamTypeStrategy.php

@@ -28,14 +28,19 @@ class ExamTypeStrategy
         $this->difficultyDistributionService = $difficultyDistributionService ?? app(DifficultyDistributionService::class);
     }
 
+    private static function defaultTotalQuestions(): int
+    {
+        return (int) config('question_bank.default_total_questions');
+    }
+
     /**
      * 根据组卷类型构建参数
-     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-错题本, 8-智能组卷(新), 9-原摸底
+     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 15-错题再练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理)
      *
      * 映射规则(前端不改,后端动态处理):
      * - 0, 9(摸底)→ 章节摸底(新逻辑)
      * - 1, 8(智能组卷)→ 按知识点顺序学习(新逻辑)
-     * - 2, 3, 4, 5 → 保持原有逻辑不变
+     * - 2, 3, 4, 5 → 保持原有逻辑不变(5 的 paper_ids 为试卷 ID;15 不入此策略,由 AssembleExamTaskJob 直组)
      */
     public function buildParams(array $baseParams, int $assembleType): array
     {
@@ -108,7 +113,7 @@ class ExamTypeStrategy
         $assembleType = (int) ($params['assemble_type'] ?? 4);
 
         $difficultyCategory = (int) ($params['difficulty_category'] ?? 1);
-        $totalQuestions = (int) ($params['total_questions'] ?? 20);
+        $totalQuestions = (int) ($params['total_questions'] ?? self::defaultTotalQuestions());
 
         Log::debug('ExamTypeStrategy: 应用难度系数分布', [
             'difficulty_category' => $difficultyCategory,
@@ -209,7 +214,7 @@ class ExamTypeStrategy
 
         $textbookId = $params['textbook_id'] ?? null;
         $grade = $params['grade'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
         $endCatalogId = $params['end_catalog_id'] ?? null; // 截止章节ID
 
         if (!$textbookId) {
@@ -284,7 +289,7 @@ class ExamTypeStrategy
         $diagnosticService = app(DiagnosticChapterService::class);
         $textbookId = $params['textbook_id'] ?? null;
         $grade = $params['grade'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
 
         Log::info('ExamTypeStrategy: 新摸底使用textbook_id', [
             'textbook_id' => $textbookId,
@@ -403,11 +408,7 @@ class ExamTypeStrategy
 
         // 组装增强参数(复用知识点组卷逻辑)
         $questionCount = count($paperQuestionIds);
-        $maxQuestions = 50; // 错题本最大题目数限制
-            $targetQuestions = (int) ($maxTotalQuestions ?? $questionCount);
-            $targetQuestions = min($targetQuestions, $maxQuestions);
-
-        // 如果题量超过最大值,按上限截取
+        $maxQuestions = 50; // 源侧题量上限,避免一次拉过多
         if ($questionCount > $maxQuestions) {
             Log::warning('ExamTypeStrategy: 错题数量超过最大值限制,已截取', [
                 'question_count' => $questionCount,
@@ -417,11 +418,15 @@ class ExamTypeStrategy
             $questionCount = $maxQuestions;
         }
 
+        $requested = (int) ($params['total_questions'] ?? self::defaultTotalQuestions());
+        $fromPaper = (int) ($maxTotalQuestions ?? $questionCount);
+        $targetQuestions = min($requested, $fromPaper, $maxQuestions);
+
         $enhanced = array_merge($params, [
             'kp_code_list' => array_values($paperKnowledgePoints),
             'paper_ids' => $paperIds,
             'paper_name' => $params['paper_name'] ?? ('追练_' . now()->format('Ymd_His')),
-            'total_questions' => $targetQuestions, // 题目数量由卷子题目规模/参数决定
+            'total_questions' => $targetQuestions, // 与 API/默认题量对齐,不超过源卷子可支撑题量
             'total_score' => $maxTotalScore ?? ($params['total_score'] ?? null),
             'difficulty_category' => $difficultyCategory,
             'is_mistake_exam' => true,
@@ -442,7 +447,7 @@ class ExamTypeStrategy
             'total_score' => $maxTotalScore
         ]);
 
-        Log::debug('ExamTypeStrategy: 错题本组卷关键参数', [
+        Log::debug('ExamTypeStrategy: 按卷追练(5)关键参数', [
             'paper_ids' => $paperIds,
             'kp_code_list' => array_values($paperKnowledgePoints),
             'difficulty_category' => $difficultyCategory,
@@ -663,7 +668,7 @@ class ExamTypeStrategy
         Log::info('ExamTypeStrategy: 构建按知识点组卷参数', $params);
 
         $studentId = $params['student_id'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
         $knowledgePointsOptions = $params['knowledge_points_options'] ?? [];
         $weaknessThreshold = $knowledgePointsOptions['weakness_threshold'] ?? 0.7;
         $focusWeaknesses = $knowledgePointsOptions['focus_weaknesses'] ?? true;
@@ -846,7 +851,7 @@ class ExamTypeStrategy
 
         $textbookId = $params['textbook_id'] ?? null;
         $grade = $params['grade'] ?? null; // 年级信息
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
         $studentId = (int) ($params['student_id'] ?? 0);
 
         if (!$textbookId) {
@@ -961,7 +966,7 @@ class ExamTypeStrategy
         }
         $kpCodeList = array_values(array_unique(array_filter($kpCodeList)));
         $studentId = $params['student_id'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
         $assembleType = (int) ($params['assemble_type'] ?? 4);
 
         if (empty($kpCodeList)) {
@@ -1145,7 +1150,7 @@ class ExamTypeStrategy
 
         $chapterIdList = $params['chapter_id_list'] ?? [];
         $studentId = $params['student_id'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
 
         // 【优化】如果用户没有指定章节,自动从教材所有有题目的章节中选择
         if (empty($chapterIdList) && !empty($params['textbook_id'])) {
@@ -2012,7 +2017,7 @@ class ExamTypeStrategy
         $textbookId = $params['textbook_id'] ?? null;
         $studentId = (int) ($params['student_id'] ?? 0);
         $grade = $params['grade'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
 
         Log::info('ExamTypeStrategy: 构建章节摸底参数', [
             'textbook_id' => $textbookId,
@@ -2024,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: 章节摸底未找到有效章节', [
@@ -2072,7 +2083,7 @@ class ExamTypeStrategy
         $textbookId = $params['textbook_id'] ?? null;
         $studentId = (int) ($params['student_id'] ?? 0);
         $grade = $params['grade'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
+        $totalQuestions = $params['total_questions'] ?? self::defaultTotalQuestions();
 
         Log::info('ExamTypeStrategy: 构建按知识点顺序学习参数', [
             'textbook_id' => $textbookId,

+ 3 - 5
app/Services/LearningAnalyticsService.php

@@ -1269,7 +1269,7 @@ class LearningAnalyticsService
 
             $studentId = $params['student_id'] ?? null;
             $grade = $params['grade'] ?? null; // 用户选择的年级
-            $totalQuestions = $params['total_questions'] ?? 20;
+            $totalQuestions = $params['total_questions'] ?? (int) config('question_bank.default_total_questions');
 
             // 【修复】参数映射:支持 kp_codes 和 kp_code_list 两种参数名
             $kpCodes = $params['kp_codes'] ?? $params['kp_code_list'] ?? [];
@@ -1377,7 +1377,6 @@ class LearningAnalyticsService
             // 【修改】错题本类型严格按错题组卷,不补充题目
             if ($isMistakeBook) {
                 $mistakeKpSource = (bool) ($params['mistake_kp_source'] ?? false);
-                // 记录错题本组卷信息
                 Log::info('LearningAnalyticsService: 错题本严格按错题组卷,不补充题目', [
                     'mistake_count' => count($priorityQuestions),
                     'assemble_type' => $assembleType,
@@ -1520,9 +1519,8 @@ class LearningAnalyticsService
                 ];
             }
 
-            // 3. 根据掌握度对题目进行筛选和排序
-            // 错题本类型:使用所有错题,但不超过最大值限制
-            $targetQuestionCount = $strictMistakeBook ? min(count($allQuestions), $maxQuestions) : $totalQuestions;
+            // 3. 根据掌握度对题目进行筛选和排序(含追练:题量与 total_questions / default_total_questions 一致,不再按 50 拉满)
+            $targetQuestionCount = min(count($allQuestions), $totalQuestions);
 
             $startTime = microtime(true);
             $selectedQuestions = $this->selectQuestionsByMastery(

+ 21 - 4
app/Services/MathFormulaProcessor.php

@@ -32,6 +32,10 @@ class MathFormulaProcessor
         // 0.5 将自定义 <image> 标签转换为标准 <img> 标签
         $content = self::convertImageTags($content);
 
+        // 0.6 规范化几何平行符:AB||CD -> AB∥CD
+        // 仅处理大写点位字母场景,避免误伤通用竖线表达式
+        $content = self::normalizeParallelSymbol($content);
+
         // 1. 【关键修复】处理公式内的双反斜杠 -> 单反斜杠
         // 数据库存储时 \sqrt 变成 \\sqrt,需要还原
         $content = self::normalizeBackslashesInDelimiters($content);
@@ -70,16 +74,29 @@ class MathFormulaProcessor
 
     /**
      * 将自定义 <image> 标签转换为标准 <img> 标签
-     * 例如:<image src="https://example.com/1.png"/> => <img src="https://example.com/1.png" />
+     * 例如:<image src="https://example.com/1.png"/> => <img ... />(宽度不超过容器,避免填空题大图撑破试卷/PDF)
      */
     private static function convertImageTags(string $content): string
     {
         // 匹配 <image src="..." /> 或 <image src="..."></image> 格式
-        return preg_replace(
+        return preg_replace_callback(
             '/<image\s+src=["\']([^"\']+)["\'](?:\s*\/>|><\/image>)/i',
-            '<img src="$1" />',
+            static function (array $m): string {
+                $src = htmlspecialchars($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+                return '<img src="'.$src.'" alt="" style="max-width:220px;max-height:60mm;width:auto;height:auto;object-fit:contain;display:block;" />';
+            },
             $content
-        );
+        ) ?? $content;
+    }
+
+    /**
+     * 将几何文本中的 ASCII 平行符替换为数学平行符(∥)
+     * 例如:AB||CD、AB || CD -> AB∥CD
+     */
+    private static function normalizeParallelSymbol(string $content): string
+    {
+        return preg_replace('/(?<!\\\\)([A-Z]{1,4})\s*\|\|\s*([A-Z]{1,4})/', '$1∥$2', $content) ?? $content;
     }
 
     /**

+ 246 - 1
app/Services/MistakeBookService.php

@@ -14,6 +14,7 @@ class MistakeBookService
 {
     protected string $learningAnalyticsBase;
     protected int $timeout;
+    private array $attemptTimelineCache = [];
 
     // 缓存时间(秒)
     const CACHE_TTL_SUMMARY = 600; // 10分钟
@@ -322,11 +323,34 @@ class MistakeBookService
                 ->take($perPage)
                 ->get();
 
+            // 批量预加载当前页作答轨迹,避免逐条查询
+            $this->preloadAttemptTimelines($mistakes);
+
             // 转换数据格式
             $data = $mistakes->map(function ($mistake) {
                 return $this->transformMistakeRecord($mistake, false);
             })->toArray();
 
+            if (($params['sort_by'] ?? 'priority_default') === 'priority_default') {
+                usort($data, function (array $a, array $b): int {
+                    $createdCmp = strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
+                    if ($createdCmp !== 0) {
+                        return $createdCmp;
+                    }
+
+                    $aPending = (($a['review_status'] ?? '') === MistakeRecord::REVIEW_STATUS_PENDING) ? 0 : 1;
+                    $bPending = (($b['review_status'] ?? '') === MistakeRecord::REVIEW_STATUS_PENDING) ? 0 : 1;
+                    if ($aPending !== $bPending) {
+                        return $aPending <=> $bPending;
+                    }
+
+                    $aLastCorrect = (($a['last_attempt_result'] ?? null) === '对') ? 1 : 0;
+                    $bLastCorrect = (($b['last_attempt_result'] ?? null) === '对') ? 1 : 0;
+
+                    return $aLastCorrect <=> $bLastCorrect;
+                });
+            }
+
             // 获取统计信息
             $summary = $this->summarize($studentId);
 
@@ -827,8 +851,11 @@ class MistakeBookService
         }
 
         // 排序
-        $sortBy = $params['sort_by'] ?? 'created_at_desc';
+        $sortBy = $params['sort_by'] ?? 'priority_default';
         match ($sortBy) {
+            'priority_default' => $query
+                // 数据库只做主排序,次排序在当前页内完成,避免相关子查询拖慢列表
+                ->orderByDesc('created_at'),
             'created_at_asc' => $query->orderBy('created_at'),
             'created_at_desc' => $query->orderByDesc('created_at'),
             'review_status_asc' => $query->orderBy('review_status'),
@@ -851,6 +878,7 @@ class MistakeBookService
             $mistake->question_id,
             $questionDetails['textbook_catalog_nodes_id'] ?? null
         );
+        $attemptTimeline = $this->buildAttemptTimeline($mistake);
 
         $data = [
             // ========== 错题记录基本信息 ==========
@@ -898,6 +926,13 @@ class MistakeBookService
 
             // ========== 知识点信息 ==========
             'knowledge_points' => $knowledgePoints,
+            // ========== 作答轨迹(错题创建后)==========
+            'attempt_timeline' => $attemptTimeline['timeline'],
+            'attempt_timeline_text' => $attemptTimeline['timeline_text'],
+            'attempt_count' => $attemptTimeline['attempt_count'],
+            'correct_count_after_mistake' => $attemptTimeline['correct_count'],
+            'last_attempt_result' => $attemptTimeline['last_result'],
+            'last_attempt_at' => $attemptTimeline['last_attempt_at'],
         ];
 
         if ($detailed) {
@@ -912,6 +947,216 @@ class MistakeBookService
         return $data;
     }
 
+    /**
+     * 构建错题创建后的作答轨迹(权威口径:student_answer_steps)
+     *
+     * 规则:
+     * 1. 仅统计当前错题创建时间之后的作答
+     * 2. 同一 exam_id + question_id 视为一次尝试
+     * 3. 一次尝试中所有步骤都正确(min_correct=1)记为「对」,否则记为「错」
+     */
+    private function buildAttemptTimeline(MistakeRecord $mistake): array
+    {
+        $empty = [
+            'timeline' => [],
+            'timeline_text' => '',
+            'attempt_count' => 0,
+            'correct_count' => 0,
+            'last_result' => null,
+            'last_attempt_at' => null,
+        ];
+
+        if (empty($mistake->student_id) || empty($mistake->question_id) || empty($mistake->created_at)) {
+            return $empty;
+        }
+
+        if (isset($this->attemptTimelineCache[$mistake->id])) {
+            return $this->attemptTimelineCache[$mistake->id];
+        }
+
+        try {
+            $attemptRows = \DB::connection('mysql')
+                ->table('student_answer_steps')
+                ->selectRaw('exam_id, question_id, MIN(is_correct) AS min_correct, MAX(created_at) AS created_at')
+                ->where('student_id', $mistake->student_id)
+                ->where('question_id', $mistake->question_id)
+                ->where('created_at', '>', $mistake->created_at)
+                ->groupBy('exam_id', 'question_id')
+                ->orderBy('created_at', 'asc')
+                ->get();
+
+            if ($attemptRows->isEmpty()) {
+                return $empty;
+            }
+
+            $timeline = [];
+            $correctCount = 0;
+            $lastAttemptAt = null;
+            foreach ($attemptRows as $row) {
+                $isCorrect = (int) ($row->min_correct ?? 0) === 1;
+
+                $result = $isCorrect ? '对' : '错';
+                $timeline[] = $result;
+                if ($isCorrect) {
+                    $correctCount++;
+                }
+                if (!empty($row->created_at)) {
+                    $lastAttemptAt = $row->created_at;
+                }
+            }
+
+            return [
+                'timeline' => $timeline,
+                'timeline_text' => implode(',', $timeline),
+                'attempt_count' => count($timeline),
+                'correct_count' => $correctCount,
+                'last_result' => end($timeline) ?: null,
+                'last_attempt_at' => $lastAttemptAt ? \Carbon\Carbon::parse($lastAttemptAt)->toISOString() : null,
+            ];
+        } catch (\Throwable $e) {
+            Log::warning('构建错题作答轨迹失败', [
+                'mistake_id' => $mistake->id,
+                'student_id' => $mistake->student_id,
+                'question_id' => $mistake->question_id,
+                'error' => $e->getMessage(),
+            ]);
+
+            return $empty;
+        }
+    }
+
+    /**
+     * 批量预加载当前页错题的作答轨迹
+     */
+    private function preloadAttemptTimelines($mistakes): void
+    {
+        $this->attemptTimelineCache = [];
+        if ($mistakes->isEmpty()) {
+            return;
+        }
+
+        $studentIds = [];
+        $questionIds = [];
+        $pairKeys = [];
+        $minCreatedByPair = [];
+        foreach ($mistakes as $mistake) {
+            $studentId = (string) ($mistake->student_id ?? '');
+            $questionId = (string) ($mistake->question_id ?? '');
+            $createdAt = (string) ($mistake->created_at ?? '');
+            if ($studentId === '' || $questionId === '' || $createdAt === '') {
+                continue;
+            }
+            $pairKey = $studentId.'|'.$questionId;
+            $pairKeys[$pairKey] = true;
+            $studentIds[$studentId] = true;
+            $questionIds[$questionId] = true;
+            if (!isset($minCreatedByPair[$pairKey]) || strcmp($createdAt, $minCreatedByPair[$pairKey]) < 0) {
+                $minCreatedByPair[$pairKey] = $createdAt;
+            }
+        }
+
+        if (empty($pairKeys)) {
+            return;
+        }
+
+        $rows = \DB::connection('mysql')
+            ->table('student_answer_steps')
+            ->whereIn('student_id', array_keys($studentIds))
+            ->whereIn('question_id', array_keys($questionIds))
+            ->orderBy('created_at', 'asc')
+            ->get(['student_id', 'question_id', 'exam_id', 'is_correct', 'created_at']);
+
+        $rowsByPair = [];
+        foreach ($rows as $row) {
+            $pairKey = (string) $row->student_id.'|'.(string) $row->question_id;
+            if (!isset($pairKeys[$pairKey])) {
+                continue;
+            }
+            $minCreatedAt = $minCreatedByPair[$pairKey] ?? null;
+            if ($minCreatedAt !== null && strcmp((string) $row->created_at, (string) $minCreatedAt) <= 0) {
+                continue;
+            }
+            $rowsByPair[$pairKey][] = $row;
+        }
+
+        foreach ($mistakes as $mistake) {
+            $pairKey = (string) ($mistake->student_id ?? '').'|'.(string) ($mistake->question_id ?? '');
+            $this->attemptTimelineCache[$mistake->id] = $this->buildAttemptTimelineFromRows(
+                $rowsByPair[$pairKey] ?? [],
+                (string) ($mistake->created_at ?? '')
+            );
+        }
+    }
+
+    /**
+     * 基于步骤记录构建错题轨迹
+     *
+     * @param array<int, object> $rows
+     */
+    private function buildAttemptTimelineFromRows(array $rows, string $thresholdCreatedAt): array
+    {
+        $empty = [
+            'timeline' => [],
+            'timeline_text' => '',
+            'attempt_count' => 0,
+            'correct_count' => 0,
+            'last_result' => null,
+            'last_attempt_at' => null,
+        ];
+        if (empty($rows) || $thresholdCreatedAt === '') {
+            return $empty;
+        }
+
+        $examAgg = [];
+        foreach ($rows as $row) {
+            $createdAt = (string) ($row->created_at ?? '');
+            if ($createdAt === '' || strcmp($createdAt, $thresholdCreatedAt) <= 0) {
+                continue;
+            }
+            $examId = (string) ($row->exam_id ?? '');
+            if ($examId === '') {
+                continue;
+            }
+            if (!isset($examAgg[$examId])) {
+                $examAgg[$examId] = [
+                    'min_correct' => 1,
+                    'created_at' => $createdAt,
+                ];
+            }
+            $examAgg[$examId]['min_correct'] = min($examAgg[$examId]['min_correct'], (int) ($row->is_correct ?? 0));
+            if (strcmp($createdAt, (string) $examAgg[$examId]['created_at']) > 0) {
+                $examAgg[$examId]['created_at'] = $createdAt;
+            }
+        }
+
+        if (empty($examAgg)) {
+            return $empty;
+        }
+
+        uasort($examAgg, fn ($a, $b) => strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? '')));
+
+        $timeline = [];
+        $correctCount = 0;
+        $lastAttemptAt = null;
+        foreach ($examAgg as $agg) {
+            $isCorrect = (int) ($agg['min_correct'] ?? 0) === 1;
+            $timeline[] = $isCorrect ? '对' : '错';
+            if ($isCorrect) {
+                $correctCount++;
+            }
+            $lastAttemptAt = (string) ($agg['created_at'] ?? $lastAttemptAt);
+        }
+
+        return [
+            'timeline' => $timeline,
+            'timeline_text' => implode(',', $timeline),
+            'attempt_count' => count($timeline),
+            'correct_count' => $correctCount,
+            'last_result' => end($timeline) ?: null,
+            'last_attempt_at' => $lastAttemptAt ? \Carbon\Carbon::parse($lastAttemptAt)->toISOString() : null,
+        ];
+    }
+
     /**
      * 清除缓存
      */

+ 13 - 0
app/Services/PaperIdGenerator.php

@@ -70,6 +70,11 @@ class PaperIdGenerator
             $id[0] = '1';
         }
 
+        // 防御式校验:必须是15位且首位非0
+        if (! self::validate($id)) {
+            throw new \RuntimeException('生成的 paper 数字ID不合法');
+        }
+
         return $id;
     }
 
@@ -84,6 +89,14 @@ class PaperIdGenerator
         return preg_match('/^[1-9]\d{14}$/', $id) === 1;
     }
 
+    /**
+     * 校验完整 paper_id(paper_ + 15位数字)
+     */
+    public static function validatePaperId(string $paperId): bool
+    {
+        return preg_match('/^paper_[1-9]\d{14}$/', $paperId) === 1;
+    }
+
     /**
      * 从ID提取时间戳
      *

+ 11 - 1
app/Services/PaperPayloadService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\Paper;
 use App\Models\PaperQuestion;
+use Illuminate\Support\Facades\Log;
 
 class PaperPayloadService
 {
@@ -115,7 +116,16 @@ class PaperPayloadService
     {
         // 提取15位数字ID作为统一学案编号
         preg_match('/paper_(\d{15})/', $paperId, $matches);
-        $paperIdNum = $matches[1] ?? preg_replace('/[^0-9]/', '', $paperId);
+        $paperIdNum = $matches[1] ?? '';
+
+        if ($paperIdNum === '') {
+            $digits = preg_replace('/[^0-9]/', '', $paperId);
+            $paperIdNum = substr(str_pad($digits, 15, '0', STR_PAD_LEFT), -15);
+            Log::warning('PaperPayloadService: paper_id 不符合标准格式,已执行15位归一化', [
+                'paper_id' => $paperId,
+                'normalized_paper_id_num' => $paperIdNum,
+            ]);
+        }
 
         return [
             'paper_id_num' => $paperIdNum,

+ 23 - 3
app/Services/QuestionBankService.php

@@ -556,9 +556,13 @@ class QuestionBankService
 
                 $assembleType = (int) $examData['assembleType'];
 
-                // 使用行业标准的Snowflake ID生成12位唯一数字ID
-                $uniqueId = PaperIdGenerator::generate();
-                $paperId = 'paper_'.$uniqueId;
+                // 允许外部预分配 paper_id,以保证接口可以同步返回稳定 ID
+                $paperId = (string) ($examData['paper_id'] ?? '');
+                if ($paperId === '') {
+                    $paperId = $this->generatePaperId();
+                } elseif (!PaperIdGenerator::validatePaperId($paperId)) {
+                    throw new \InvalidArgumentException("paper_id 格式不合法: {$paperId}");
+                }
                 $studentId = $examData['student_id'] ?? '';
                 $studentName = '________';
                 if (! empty($studentId)) {
@@ -776,6 +780,22 @@ class QuestionBankService
         return null;
     }
 
+    /**
+     * 生成试卷ID(paper_ + 15位数字)
+     */
+    public function generatePaperId(): string
+    {
+        // 防御式重试,确保始终产出合法 15 位数字部分
+        for ($i = 0; $i < 3; $i++) {
+            $uniqueId = PaperIdGenerator::generate();
+            if (PaperIdGenerator::validate($uniqueId)) {
+                return 'paper_'.$uniqueId;
+            }
+        }
+
+        throw new \RuntimeException('无法生成合法的 paper_id');
+    }
+
     private function useLocal(): bool
     {
         return true;

+ 123 - 0
app/Services/QuestionDifficultyResolver.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class QuestionDifficultyResolver
+{
+    private const TABLE = 'question_difficulty_calibrations';
+    private const SERVED_DIFF_CONFIDENCE_DENOMINATOR = 20.0;
+
+    private ?bool $tableReady = null;
+
+    /**
+     * @param  array<int, int|string>  $questionIds
+     * @return array<int, array<string, float|null>> question_bank_id => calibration snapshot
+     */
+    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)
+            ->get(['question_bank_id', 'original_difficulty', 'calibrated_difficulty', 'weighted_attempts'])
+            ->mapWithKeys(function ($row) {
+                $qid = (int) ($row->question_bank_id ?? 0);
+                if ($qid <= 0) {
+                    return [];
+                }
+
+                return [
+                    $qid => [
+                        'original_difficulty' => $row->original_difficulty !== null ? (float) $row->original_difficulty : null,
+                        'calibrated_difficulty' => $row->calibrated_difficulty !== null ? (float) $row->calibrated_difficulty : null,
+                        'weighted_attempts' => $row->weighted_attempts !== null ? (float) $row->weighted_attempts : null,
+                    ],
+                ];
+            })
+            ->all();
+    }
+
+    /**
+     * 批量给题目数组计算组卷使用难度 served_diff:
+     * served_diff = alpha * calibrated + (1 - alpha) * original
+     * alpha = min(1, weighted_attempts / 20)
+     * 前提:仅当 calibrated_difficulty 存在时才融合;否则保持原始 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)) {
+                continue;
+            }
+
+            $snapshot = $map[$id] ?? [];
+            $calibrated = $snapshot['calibrated_difficulty'] ?? null;
+            if ($calibrated === null) {
+                continue;
+            }
+
+            $original = isset($q['difficulty']) && is_numeric($q['difficulty'])
+                ? (float) $q['difficulty']
+                : (float) ($snapshot['original_difficulty'] ?? $calibrated);
+            $weightedAttempts = max(0.0, (float) ($snapshot['weighted_attempts'] ?? 0.0));
+            $alpha = min(1.0, $weightedAttempts / self::SERVED_DIFF_CONFIDENCE_DENOMINATOR);
+            $servedDifficulty = ($alpha * (float) $calibrated) + ((1.0 - $alpha) * $original);
+
+            $q['difficulty'] = round($servedDifficulty, 4);
+            $q['difficulty_source'] = $alpha >= 0.9999 ? 'calibrated' : 'served_blend';
+            $q['difficulty_original'] = round($original, 4);
+            $q['difficulty_calibrated'] = round((float) $calibrated, 4);
+            $q['difficulty_alpha'] = round($alpha, 4);
+            $q['difficulty_weighted_attempts'] = round($weightedAttempts, 4);
+        }
+        unset($q);
+
+        return $questions;
+    }
+
+    private function isReady(): bool
+    {
+        if ($this->tableReady !== null) {
+            return $this->tableReady;
+        }
+
+        $this->tableReady = Schema::hasTable(self::TABLE);
+
+        return $this->tableReady;
+    }
+}

+ 26 - 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
@@ -672,6 +678,19 @@ class QuestionLocalService
             return [];
         }
 
+        $questions = $this->questionDifficultyResolver->applyCalibratedDifficulty($questions);
+        $calibratedCount = count(array_filter($questions, fn ($q) => ($q['difficulty_source'] ?? null) === 'calibrated'));
+        $servedBlendCount = count(array_filter($questions, fn ($q) => ($q['difficulty_source'] ?? null) === 'served_blend'));
+        Log::info('QuestionLocalService: 组卷前应用校准难度', [
+            'total_candidates' => count($questions),
+            'calibrated_candidates' => $calibratedCount,
+            'served_blend_candidates' => $servedBlendCount,
+        ]);
+
+        $resolveQuestionId = static function (array $question): string {
+            return (string) ($question['id'] ?? $question['question_id'] ?? $question['question_bank_id'] ?? '');
+        };
+
         // 【恢复】简化逻辑,避免复杂处理
         $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
 
@@ -709,8 +728,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++;
@@ -758,8 +777,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++;
@@ -770,8 +789,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;
                     }
                 }

+ 2 - 2
app/Services/TaskManager.php

@@ -44,8 +44,8 @@ class TaskManager
             'created_at' => now()->toISOString(),
             'updated_at' => now()->toISOString(),
             'callback_url' => $data['callback_url'] ?? null,
-            // 【优化】根据任务类型设置不同的超时时间
-            'expires_at' => now()->addSeconds($type === self::TASK_TYPE_EXAM ? 45 : 30)->toISOString(),
+            // 组卷+PDF都异步后,exam 任务时窗适当放宽,避免回调被误判超时
+            'expires_at' => now()->addSeconds($type === self::TASK_TYPE_EXAM ? 240 : 30)->toISOString(),
         ];
 
         $this->saveTask($taskId, $taskData);

+ 326 - 0
app/Support/BlankPlaceholderRenderer.php

@@ -0,0 +1,326 @@
+<?php
+
+namespace App\Support;
+
+class BlankPlaceholderRenderer
+{
+    private const DEFAULT_BLANK_SPAN = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
+    // 仅匹配“空白占位”型 underline,不匹配 \underline{\frac{...}} 这类有内容公式下划线
+    private const BLANK_UNDERLINE_PATTERN = '/\\\\+underline\{\s*(?:(?:\\\\+qquad+|\\\\+quad+|\\\\+hspace\{[^{}]*\}|\\\\+hphantom\{\s*(?:(?:\\\\+qquad+|\\\\+quad+|\\\\+hspace\{[^{}]*\}|_{2,}|&nbsp;|&#160;|\s| |\\\\+\s+)*)\s*\}|_{2,}|&nbsp;|&#160;|\s| |\\\\+\s+)*)\s*\}/u';
+
+    /**
+     * \left(\quad\right) / \left(\qquad\right) 中的 \quad 是合法间距,不是填空占位;替换前临时保护以免误伤。
+     *
+     * @return array{0:string,1:array<string,string>}
+     */
+    private static function protectLeftRightQuadPairs(string $inner): array
+    {
+        $map = [];
+        $idx = 0;
+
+        // 使用 \x5C 避免 PCRE 将 \left / \quad 中的 \l、\q 解析为无效转义
+        $protected = preg_replace_callback(
+            '/\x5Cleft\s*\(\s*(?:\x5Cquad|\x5Cqquad)\s*\x5Cright\s*\)/u',
+            static function (array $m) use (&$map, &$idx): string {
+                $key = '<<<LR_PAIR_'.$idx.'>>>';
+                $map[$key] = $m[0];
+                $idx++;
+
+                return $key;
+            },
+            $inner
+        );
+
+        return [$protected ?? $inner, $map];
+    }
+
+    /**
+     * @param  array<string,string>  $restoreMap
+     */
+    private static function restoreProtectedLeftRightQuadPairs(string $inner, array $restoreMap): string
+    {
+        if ($restoreMap === []) {
+            return $inner;
+        }
+
+        return str_replace(array_keys($restoreMap), array_values($restoreMap), $inner);
+    }
+
+    /**
+     * 数学片段内因占位拆分后的分段,按顺序交替输出「小段 $...$」与 HTML 空位;避免首尾空分段导致半截 `$` 或与 HTML 错位。
+     *
+     * @param  array<int,string>  $parts
+     */
+    private static function rebuildMathSegmentsWithBlankSpans(array $parts, string $blankSpan): string
+    {
+        $rebuilt = '';
+        $lastIndex = count($parts) - 1;
+
+        foreach ($parts as $index => $part) {
+            if ($part !== '') {
+                if (preg_match('/^[\..。]$/u', $part)) {
+                    $rebuilt .= $part;
+                } else {
+                    $rebuilt .= htmlspecialchars('$'.$part.'$', ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                }
+            }
+            if ($index < $lastIndex) {
+                $rebuilt .= $blankSpan;
+            }
+        }
+
+        return $rebuilt === '' ? $blankSpan : $rebuilt;
+    }
+
+    /**
+     * 脏数据常见:$a=______ 后紧跟中文但漏写闭合 $,导致下一个 $...$ 整段被吞并。
+     * 在连续下划线占位后、若紧接着汉字/全角逗号且中间仍无第二个 $,则补一个闭合 $。
+     *
+     * 规范写法「$a$=__________时」在横线前的 $ 是 $a$ 的闭合符,紧跟 =,不得在此插 $(否则会得到 ...=____$时)。
+     * 因此仅当「该 $ 后面第一个非 $ 片段不是以 = 开头接上横线」时…… 更简:(?! =) 在匹配起点 $ 之后:若紧跟 = 则本规则不锚定在此 $(见下 (?!=))。
+     */
+    private static function closeMissingDollarAfterUnderscoreBlank(string $content): string
+    {
+        // 不能在「已成对的 $…$」的闭合 $ 上锚定:否则会把 $40^{\circ}$ 的收尾 $ 当成新段开头,
+        // 一路吞到后面 ______度,错误插入「______$度」(见 questions.id=332)。
+        if (! preg_match_all('/\$(?!=)([^$]*_{2,})(?=[\p{Han},。;])/u', $content, $matches, PREG_OFFSET_CAPTURE)) {
+            return $content;
+        }
+
+        $out = $content;
+        foreach (array_reverse($matches[0]) as [$text, $byteOffset]) {
+            $before = substr($out, 0, $byteOffset);
+            if ((substr_count($before, '$') % 2) === 1) {
+                continue;
+            }
+            $len = strlen($text);
+            $out = substr_replace($out, $text.'$', $byteOffset, $len);
+        }
+
+        return $out;
+    }
+
+    /**
+     * 选择题常见:答案区写成 $=\left(\quad\right)$,意图为答题横线而非 LaTeX 括号间距。
+     * 将段尾的 $=\left(\quad\right)$ / $=\left(\qquad\right)$ 拆成「公式到等号为止」+ 段外连续下划线,后续由 _{2,} 规则换成标准空位。
+     */
+    private static function moveTrailingLeftQuadRightAnswerBlankToUnderscores(string $content): string
+    {
+        $suffixQuad = '=\\left(\\quad\\right)';
+        $suffixQquad = '=\\left(\\qquad\\right)';
+
+        $out = preg_replace_callback(
+            '/\$(?:[^\$]|\\\\.)*?\\$/u',
+            static function (array $m) use ($suffixQuad, $suffixQquad): string {
+                $full = $m[0];
+                $inner = mb_substr($full, 1, mb_strlen($full) - 2);
+                $suffixLen = null;
+                if (str_ends_with($inner, $suffixQuad)) {
+                    $suffixLen = mb_strlen($suffixQuad);
+                } elseif (str_ends_with($inner, $suffixQquad)) {
+                    $suffixLen = mb_strlen($suffixQquad);
+                }
+                if ($suffixLen !== null && $suffixLen <= mb_strlen($inner)) {
+                    $prefix = mb_substr($inner, 0, mb_strlen($inner) - $suffixLen);
+
+                    return '$'.$prefix.'=$'.'__________';
+                }
+
+                return $full;
+            },
+            $content
+        );
+
+        return $out ?? $content;
+    }
+
+    /**
+     * 将题干中的空括号/下划线/部分异常占位符统一替换为标准空位样式。
+     *
+     * @return array{0:string,1:bool} [renderedContent, replacedAnyPlaceholder]
+     */
+    public static function replaceToBlankSpan(
+        string $content,
+        ?string $blankSpan = null,
+        bool $collapseAdjacentBlanks = false,
+        bool $normalizeChineseTerminalPeriod = true
+    ): array
+    {
+        $blankSpan = $blankSpan ?: self::DEFAULT_BLANK_SPAN;
+        $renderedContent = self::closeMissingDollarAfterUnderscoreBlank($content);
+        $renderedContent = self::moveTrailingLeftQuadRightAnswerBlankToUnderscores($renderedContent);
+
+        $latexPlaceholders = [];
+        $counter = 0;
+        // 非贪婪:遇到第一个闭合 $ 即结束;避免紧邻多段 "$...$…$…$" 时被吞成一段(混入中文标点,破坏公式边界)。
+        $renderedContent = preg_replace_callback('/\$(?:[^\$]|\\\\.)*?\\$/u', function ($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
+            $latexContent = $matches[0];
+            $inner = mb_substr($latexContent, 1, mb_strlen($latexContent) - 2);
+
+            // \left(\quad\right) 先保护,避免下方 \quad 替换误伤(见选择题题干中的合法间距)。
+            [$inner, $lrQuadRestore] = self::protectLeftRightQuadPairs($inner);
+
+            // 数学环境内也可能包含填空占位符(如 $\\underline{\\qquad}$ / $\\angle A=\\underline{\\quad}$)
+            $blankToken = '<<<BLANK_IN_MATH_'.$counter.'>>>';
+            $innerWithBlanks = preg_replace(
+                [
+                    self::BLANK_UNDERLINE_PATTERN,
+                    '/\\\\+qquad+/u',
+                    '/\\\\+quad+/u',
+                    '/[((](?:\s|&nbsp;|&#160;| )*[))]/u',
+                    '/_{2,}/u',
+                ],
+                $blankToken,
+                $inner,
+                -1,
+                $blankCount
+            );
+            $innerWithBlanks = self::restoreProtectedLeftRightQuadPairs($innerWithBlanks, $lrQuadRestore);
+            if ($blankCount > 0) {
+                $parts = explode($blankToken, $innerWithBlanks);
+
+                return self::rebuildMathSegmentsWithBlankSpans($parts, $blankSpan);
+            }
+
+            $placeholder = '<<<LATEX_BLANK_'.$counter.'>>>';
+            $latexPlaceholders[$placeholder] = $latexContent;
+            $counter++;
+
+            return $placeholder;
+        }, $renderedContent);
+
+        // 兼容常见空位写法:\underline{...}、\qquad、空括号(含 nbsp 等空白)、连续下划线、尾部 \\$
+        $patterns = [
+            self::BLANK_UNDERLINE_PATTERN,
+            '/\\\\+qquad+/u',
+            '/[((](?:\s|&nbsp;|&#160;| )*[))]/u',
+            '/_{2,}/u',
+            '/\\\\+\$(?=\s*$)/u',
+        ];
+        $renderedContent = preg_replace($patterns, $blankSpan, $renderedContent);
+        if ($collapseAdjacentBlanks) {
+            $quotedBlankSpan = preg_quote($blankSpan, '/');
+            $renderedContent = preg_replace('/(?:'.$quotedBlankSpan.'(?:\s|&nbsp;|&#160;| )*){2,}/u', $blankSpan, $renderedContent);
+        }
+        // 兼容脏数据:空位后紧跟孤立 "$" 且位于句尾(如 "...=____$."),移除该孤立 "$"。
+        // 仅作用在“标准空位 + 句尾”场景,不影响正常数学公式分隔符。
+        $quotedBlankSpan = preg_quote($blankSpan, '/');
+        $renderedContent = preg_replace(
+            '/('.$quotedBlankSpan.')\s*\$(?=\s*[\..。]?(?:\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$)/u',
+            '$1',
+            $renderedContent
+        ) ?? $renderedContent;
+
+        foreach ($latexPlaceholders as $placeholder => $latexContent) {
+            if (preg_match('/^\$(.*?)(\\\\+)\$$/u', $latexContent, $match)) {
+                $inner = rtrim($match[1]);
+                if ($inner === '' || preg_match('/[=::]\s*$/u', $inner)) {
+                    if ($inner === '') {
+                        $replacement = $blankSpan;
+                    } else {
+                        $replacement = htmlspecialchars('$'.$inner.'$', ENT_QUOTES | ENT_HTML5, 'UTF-8').' '.$blankSpan;
+                    }
+                    $renderedContent = str_replace($placeholder, $replacement, $renderedContent);
+                    continue;
+                }
+            }
+
+            $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            $renderedContent = str_replace($placeholder, $encodedLatex, $renderedContent);
+        }
+
+        if ($normalizeChineseTerminalPeriod) {
+            $renderedContent = self::normalizeChineseTerminalPeriod($renderedContent);
+        }
+
+        return [$renderedContent, $renderedContent !== $content];
+    }
+
+    public static function defaultBlankSpan(): string
+    {
+        return self::DEFAULT_BLANK_SPAN;
+    }
+
+    /**
+     * 统一句尾标点(仅处理句尾,不影响中间小数/表达式)
+     *
+     * $mode:
+     * - remove: 去掉句尾句号
+     * - dot:    句尾统一为英文实心点 "."
+     * - cn:     句尾统一为中文句号 "。"
+     */
+    public static function normalizeTerminalPunctuation(string $content, string $mode): string
+    {
+        $replacement = match ($mode) {
+            'remove' => '',
+            'dot' => '.',
+            'cn' => '。',
+            default => null,
+        };
+        if ($replacement === null) {
+            return $content;
+        }
+
+        // 仅处理句尾最后一个标点(允许句尾带 HTML 标签,如 <image .../>)。
+        // 1) 先处理数学片段尾点(如 "$.$" / "$。$" / "$.$")。
+        if (preg_match('/^(.*)\$\s*[\..。]\s*\$(\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content, $m)) {
+            return $m[1].$replacement.$m[2];
+        }
+
+        // 2) 再处理普通句尾点(只替换最后一个,不影响中间文本)。
+        if (preg_match('/^(.*?)([\..。])(\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/us', $content, $m)) {
+            return $m[1].$replacement.$m[3];
+        }
+
+        return $content;
+    }
+
+    /**
+     * 仅当句尾不存在句号类标点时,追加目标标点。
+     * 不会覆盖已存在的句尾标点,也不处理正文中间内容。
+     */
+    public static function appendTerminalPunctuationIfMissing(string $content, string $punctuation): string
+    {
+        if ($punctuation === '') {
+            return $content;
+        }
+
+        // 末尾若是图像类媒体标签(例如 <image .../>),不追加标点,避免出现独立一行的孤立小点。
+        if (preg_match('/(?:<\s*image\b[^>]*\/?>|<\s*img\b[^>]*\/?>|<\s*svg\b[\s\S]*<\/\s*svg\s*>)(?:\s*(?:(?:<\/[^>]+>|<[^>]+\/>)\s*)*)$/iu', $content)) {
+            return $content;
+        }
+
+        // 句尾若已有终止符号(按“可见文本”判断,避免把 HTML 实体中的分号误判为终止符)
+        $visibleText = html_entity_decode(strip_tags($content), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+        $visibleText = rtrim($visibleText);
+        if ($visibleText !== '' && preg_match('/[\..。!!\??;;::]$/u', $visibleText)) {
+            return $content;
+        }
+
+        return rtrim($content).$punctuation;
+    }
+
+    /**
+     * 填空题常见写法:"...。 (说明)" / "...。(说明)"(后面可能跟图片标签)
+     * 将该处句号统一为英文小点,避免句尾媒体标签场景下出现错误补点。
+     */
+    public static function normalizePeriodBeforeTrailingParentheticalNote(string $content, string $replacement = '.'): string
+    {
+        return preg_replace(
+            '/[。.\.](?=\s*[((][^))]{1,80}[))]\s*(?:(?:<[^>]+>\s*)*)$)/u',
+            $replacement,
+            $content,
+            1
+        ) ?? $content;
+    }
+
+    private static function normalizeChineseTerminalPeriod(string $content): string
+    {
+        // 仅在存在中文语境时,把句末英文句号统一为中文句号。
+        if (! preg_match('/\p{Han}/u', $content)) {
+            return $content;
+        }
+
+        return self::normalizeTerminalPunctuation($content, 'cn');
+    }
+}

+ 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);
     }
 }

+ 2 - 25
app/Support/GradingStyleQuestionStem.php

@@ -122,31 +122,8 @@ class GradingStyleQuestionStem
      */
     private static function applyBlankPlaceholdersLikeGrading(string $stemLine): string
     {
-        $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-        $renderedStem = $stemLine;
-        $renderedStem = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedStem);
-        $renderedStem = preg_replace('/\\\qquad+/', $blankSpan, $renderedStem);
-
-        $latexPlaceholders = [];
-        $counter = 0;
-        $renderedStem = preg_replace_callback('/\$[^$]+\$/u', function ($matches) use (&$latexPlaceholders, &$counter) {
-            $placeholder = '<<<LATEX_'.$counter.'>>>';
-            $latexPlaceholders[$placeholder] = $matches[0];
-            $counter++;
-
-            return $placeholder;
-        }, $renderedStem);
-
-        $renderedStem = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedStem);
-
-        foreach ($latexPlaceholders as $placeholder => $latexContent) {
-            $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-            $renderedStem = str_replace($placeholder, $encodedLatex, $renderedStem);
-        }
-
-        if ($renderedStem === $stemLine) {
-            $renderedStem .= ' '.$blankSpan;
-        }
+        [$renderedStem] = BlankPlaceholderRenderer::replaceToBlankSpan($stemLine, null, true, false);
+        $renderedStem = BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedStem, 'remove');
 
         return $renderedStem;
     }

+ 1 - 3
app/Support/JudgeCardTemplateBuilder.php

@@ -4,8 +4,6 @@ namespace App\Support;
 
 class JudgeCardTemplateBuilder
 {
-    private const TOTAL_QUESTIONS = 20;
-
     public function build(): array
     {
         $template = config('exam.judge_card_template', []);
@@ -37,7 +35,7 @@ class JudgeCardTemplateBuilder
             'label_width' => (int) data_get($template, 'layout.label_width', 180),
             'label_to_box_gap' => (int) data_get($template, 'layout.label_to_box_gap', 16),
         ];
-        $questions = self::TOTAL_QUESTIONS;
+        $questions = max(1, (int) config('question_bank.default_total_questions'));
         $questionBoxCounts = $this->buildQuestionBoxCountsByRule($questions);
 
         return [

+ 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

+ 12 - 1
app/Support/PaperNaming.php

@@ -2,6 +2,7 @@
 
 namespace App\Support;
 
+use App\Services\PaperIdGenerator;
 use InvalidArgumentException;
 
 class PaperNaming
@@ -14,6 +15,7 @@ class PaperNaming
             2 => '知识点组题',
             3 => '教材组题',
             5 => '智能追练',
+            15 => '错题再练',
             default => throw new InvalidArgumentException("不支持的 assemble_type: {$assembleType}"),
         };
     }
@@ -21,7 +23,16 @@ class PaperNaming
     public static function extractExamCode(string $paperId): string
     {
         preg_match('/paper_(\d{15})/', $paperId, $matches);
-        $examCode = $matches[1] ?? preg_replace('/[^0-9]/', '', $paperId);
+        $examCode = $matches[1] ?? '';
+
+        if ($examCode === '') {
+            $digits = preg_replace('/[^0-9]/', '', $paperId);
+            $examCode = substr(str_pad($digits, 15, '0', STR_PAD_LEFT), -15);
+        }
+
+        if (!PaperIdGenerator::validate($examCode)) {
+            throw new InvalidArgumentException("无效的考试编码: {$paperId}");
+        }
 
         return $examCode !== '' ? $examCode : $paperId;
     }

+ 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-* 头

+ 3 - 1
composer.json

@@ -83,7 +83,9 @@
     },
     "extra": {
         "laravel": {
-            "dont-discover": []
+            "dont-discover": [
+                "laravel/pail"
+            ]
         }
     },
     "config": {

+ 143 - 0
config/exam.php

@@ -90,4 +90,147 @@ return [
             'blank_is_wrong' => true,
         ],
     ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 学情报告 V3 话术配置
+    |--------------------------------------------------------------------------
+    |
+    | difficulty_explain_messages: 难度说明话术池
+    | 第一层 key: 难度状态(匹配/偏难/偏易/暂无)
+    | 第二层 key: 得分率档位(high/mid/low/unknown)
+    |
+    */
+    'analysis_report_v3' => [
+        'first_exam_messages_by_bucket' => [
+            // 分档:A(90-100), B(75-89), C(60-74), D(40-59), E(0-39)
+            'A' => [
+                '这次开局很稳,说明你的基础和状态都在线。',
+                '第一次就拿到高分,后续保持节奏会更强。',
+                '你的学习方法是有效的,继续按这个路径推进。',
+                '这是一个很好的起点,接下来可以适度挑战难题。',
+                '成绩很亮眼,说明你已经具备较强的掌握能力。',
+                '你的投入有明显回报,继续保持就会持续领先。',
+                '开局高分值得肯定,下一步重点是稳定输出。',
+                '这次表现优秀,后续可以往“又快又准”再升级。',
+                '你已经在高水平区间,继续打磨细节会更出色。',
+                '这是非常有竞争力的起步,继续冲就对了。',
+            ],
+            'B' => [
+                '这个分数是很不错的起点,方向完全正确。',
+                '你已经进入良好区间,再补几处薄弱点就能上台阶。',
+                '开局表现可圈可点,继续练会更稳定。',
+                '说明你有扎实基础,后续提升空间也很清晰。',
+                '这次成绩不错,下一步就是把失分点逐个清掉。',
+                '起步良好,继续保持专注,进步会很快。',
+                '你已经具备不错的能力,差的是一点点细节打磨。',
+                '这个起点很健康,后续很有机会冲到更高档。',
+                '成绩说明你在正轨上,继续按计划推进就行。',
+                '这次发挥稳定,接下来把短板补齐会很明显。',
+            ],
+            'C' => [
+                '这是正常且可提升的起点,先稳住基础最关键。',
+                '你已经有一定掌握度,接下来重点是补薄弱模块。',
+                '这个分数段提升通常很快,方向对了就会涨。',
+                '开局在中位区间,不焦虑,持续练习就会突破。',
+                '先把常错题型吃透,你的分数会明显上来。',
+                '这次结果能帮我们精准定位问题,价值很大。',
+                '起点清晰、空间也清晰,后续提升可期待。',
+                '你的基础在,下一步要把稳定性做出来。',
+                '这个阶段最怕放弃,最值得坚持。',
+                '继续按节奏推进,很快就能看到上升曲线。',
+            ],
+            'D' => [
+                '第一次这个分数不代表上限,只代表当前起点。',
+                '现在最重要的是先建立信心,再逐步提分。',
+                '这次结果很有价值,能帮你更精准地补基础。',
+                '先把核心概念补牢,分数会先稳再升。',
+                '这个阶段提升潜力很大,方法对了进步会很快。',
+                '不用和别人比,先和昨天的自己比就很好。',
+                '先做对“会做的题”,再攻“有难度的题”。',
+                '你现在需要的是节奏和耐心,不是否定自己。',
+                '起步偏低很常见,持续练习就会逐渐反转。',
+                '只要不放弃,这个分段通常最容易拉开增幅。',
+            ],
+            'E' => [
+                '第一次分数偏低很正常,先把学习路径走顺。',
+                '这不是结论,只是起点,我们从基础一点点重建。',
+                '先把会做题做稳,信心会先回来。',
+                '现在最关键的是“稳基础、慢提速”。',
+                '低分并不定义能力,持续训练才会定义结果。',
+                '先把核心知识补齐,后续提升会很明显。',
+                '今天看到的是起点,不是终点。',
+                '你需要的是清晰步骤,不是压力。',
+                '每次进步一点点,累计起来会很惊人。',
+                '从现在开始,踏实走每一步,结果一定会变。',
+            ],
+        ],
+        'difficulty_explain_messages' => [
+            '匹配' => [
+                'high' => [
+                    '本次题目难度与目标基本一致,且得分表现优秀,说明当前掌握质量与稳定性都较好。',
+                    '本卷难度与学案目标贴合,你在该难度下保持了高得分,当前阶段学习效果较扎实。',
+                    '难度匹配且得分率高,结果可信度高,可作为当前能力水平的有效反映。',
+                ],
+                'mid' => [
+                    '本次题目难度与目标一致,当前得分处于可提升区间,建议围绕失分点做定向巩固。',
+                    '难度匹配,成绩能够真实反映现阶段水平;下一步重点是把薄弱题型转为稳定得分。',
+                    '在目标难度下表现中位,说明基础已建立,建议通过专题训练提升稳定性。',
+                ],
+                'low' => [
+                    '本次题目难度与目标一致,当前得分偏低主要反映掌握度不足,建议先补核心基础再提速。',
+                    '难度与目标基本匹配,分数偏低具有诊断价值,优先处理高频错因会更有效。',
+                    '在匹配难度下得分偏低,建议先稳基础模块,再逐步扩大题型覆盖面。',
+                ],
+                'unknown' => [
+                    '本次题目整体难度与学案目标基本一致,结果可直接反映当前掌握水平。',
+                ],
+            ],
+            '偏难' => [
+                'high' => [
+                    '本次题目整体偏难,但你仍保持了高得分,说明在高压难度下也具备较强解题稳定性。',
+                    '虽然本卷难度高于目标区间,你依然取得了优秀得分,体现出明显的能力上限优势。',
+                    '题目偏难且得分率仍高,当前阶段可适度增加高阶题比例,持续验证上限。',
+                ],
+                'mid' => [
+                    '本次题目偏难,当前得分处于中位区间属正常表现,建议先巩固中档再逐步冲高档。',
+                    '在高于目标的难度下取得当前成绩,说明基础可用;下一步可聚焦中高难过渡题。',
+                    '题目整体偏难,分数受客观难度影响,建议优先补齐同模块关键中档题。',
+                ],
+                'low' => [
+                    '本次题目整体偏难,低得分中包含客观难度因素,建议先回到目标难度做稳态提升。',
+                    '由于题目难度超出目标区间,当前分数偏低可理解,建议先补齐基础与中档能力。',
+                    '本卷偏难且得分偏低,建议先通过同模块中档题建立稳定正确率,再冲高难。',
+                ],
+                'unknown' => [
+                    '本次题目整体偏难,错误率偏高有客观因素,建议先补齐同模块中档题再冲高档。',
+                ],
+            ],
+            '偏易' => [
+                'high' => [
+                    '本次题目整体偏易且得分较高,说明基础掌握较稳,建议补充更高一档难度验证上限。',
+                    '在低于目标难度的试卷上保持高得分,建议增加中高难题比例做进一步校准。',
+                    '题目偏易时获得高分符合预期,下一步可通过更高难度题检验真实上限。',
+                ],
+                'mid' => [
+                    '本次题目偏易但得分仍有提升空间,建议先排查基础失分点并提高稳定正确率。',
+                    '在偏易试卷下表现中位,说明基础环节仍有波动,建议先做易中题稳定训练。',
+                    '难度低于目标但得分未显著拉开,建议优先修复粗心与步骤性失分。',
+                ],
+                'low' => [
+                    '本次题目整体偏易,得分偏低提示基础环节存在短板,建议先做基础题稳固训练。',
+                    '在偏易难度下分数仍偏低,建议优先回补核心概念与高频基础题型。',
+                    '题目偏易但得分不理想,需先解决基础正确率问题,再考虑提升难度。',
+                ],
+                'unknown' => [
+                    '本次题目整体偏易,若得分高不代表上限已到,建议补充更高一档难度验证稳定性。',
+                ],
+            ],
+            '暂无' => [
+                'unknown' => [
+                    '暂无足够数据评估难度匹配。',
+                ],
+            ],
+        ],
+    ],
 ];

+ 21 - 0
config/pdf.php

@@ -23,4 +23,25 @@ return [
     |
     */
     'include_kp_explain_default' => env('PDF_INCLUDE_KP_EXPLAIN', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Chrome 轮询超时(秒)
+    |--------------------------------------------------------------------------
+    |
+    | 主渲染通道等待 PDF 文件落地的最长时长;超时后会快速进入最小参数兜底。
+    | 默认 40 秒(旧逻辑固定 80 秒),用于降低异常场景下的长时间卡顿。
+    |
+    */
+    'chrome_poll_timeout_seconds' => (int) env('PDF_CHROME_POLL_TIMEOUT_SECONDS', 40),
+
+    /*
+    |--------------------------------------------------------------------------
+    | 远程图片尺寸探测超时(秒)
+    |--------------------------------------------------------------------------
+    |
+    | 扁图自适应会探测远程图片宽高。为防止慢图源拖慢 PDF,探测使用短超时。
+    |
+    */
+    'image_probe_timeout_seconds' => (int) env('PDF_IMAGE_PROBE_TIMEOUT_SECONDS', 2),
 ];

+ 5 - 0
config/question_bank.php

@@ -13,4 +13,9 @@ return [
 
     /** 知识点题量统计:教材章节顺序用哪一册(textbooks.semester,默认 2=下学期) */
     'kp_stats_semester' => (int) env('KP_STATS_SEMESTER', 2),
+
+    /**
+     * 智能组卷默认题量(单一配置源:业务代码请用 config('question_bank.default_total_questions'),勿硬编码题数)
+     */
+    'default_total_questions' => (int) env('DEFAULT_TOTAL_QUESTIONS', 10),
 ];

+ 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');
+    }
+};
+

+ 283 - 0
public/mockups/cluster-focus-demo.html

@@ -0,0 +1,283 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>知识点聚类连线 - PDF场景复刻</title>
+  <style>
+    :root {
+      --bg: #f5f7fb;
+      --card: #ffffff;
+      --line: #0f172a;         /* 连线颜色(独立于标签文字) */
+      --line-dense: #111827;   /* 密集场景更深线色 */
+      --text: #0f172a;
+      --muted: #64748b;
+      --tag-text: #92400e;     /* 标签文字色 */
+      --dot-mastered: #22c55e;
+      --dot-weak: #f59e0b;
+      --dot-beginner: #ef4444;
+      --dot-unlearned: #d1d5db;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
+      color: var(--text);
+      background: var(--bg);
+      padding: 24px;
+    }
+    .wrap { max-width: 1420px; margin: 0 auto; }
+    h1 { margin: 0 0 8px; font-size: 34px; }
+    .legend { margin-bottom: 18px; color: var(--muted); font-size: 18px; }
+    .legend .dot {
+      width: 13px; height: 13px; border-radius: 999px; display: inline-block; margin: 0 6px 0 16px;
+      vertical-align: middle;
+    }
+    .grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 16px;
+    }
+    .card {
+      background: var(--card);
+      border: 1px solid #dfe4ea;
+      border-radius: 16px;
+      padding: 14px 16px 16px;
+      min-height: 250px;
+    }
+    .card h2 { margin: 0 0 10px; font-size: 38px; }
+    .group {
+      border-left: 3px solid #e5e7eb;
+      margin-bottom: 10px;
+      padding-left: 10px;
+      position: relative;
+      padding-right: 230px; /* 右侧留给标签区 */
+    }
+    .group-title { font-size: 34px; font-weight: 700; margin-bottom: 6px; line-height: 1.25; }
+    .points {
+      display: flex;
+      gap: 7px;
+      align-items: center;
+      flex-wrap: wrap;
+      min-height: 20px;
+      position: relative;
+    }
+    .point {
+      width: 16px;
+      height: 16px;
+      border-radius: 4px;
+      border: 1px solid #bfc7d1;
+      position: relative;
+      flex: 0 0 auto;
+    }
+    .mastered { background: var(--dot-mastered); }
+    .weak { background: var(--dot-weak); }
+    .beginner { background: var(--dot-beginner); }
+    .unlearned { background: var(--dot-unlearned); }
+
+    .point.focus {
+      box-shadow: 0 0 0 3px rgba(253, 224, 71, .45);
+    }
+    .focus-tag {
+      position: absolute;
+      left: 138px; /* 与最右侧方块拉开安全间距 */
+      top: 50%;
+      transform: translateY(-50%);
+      border: 2px solid var(--line);
+      border-radius: 999px;
+      padding: 2px 10px;
+      font-size: 27px;
+      line-height: 1.3;
+      color: var(--tag-text);
+      background: #fff7ed;
+      white-space: nowrap;
+      max-width: none;
+      overflow: visible;
+      text-overflow: clip;
+    }
+    .focus-tag.dense { left: 178px; top: 50%; }
+    .focus-tag.bottom { left: 148px; top: 44%; }
+
+    .focus-svg {
+      position: absolute;
+      left: 0;
+      top: -36px;
+      width: 162px;
+      height: 64px;
+      overflow: visible;
+      pointer-events: none;
+    }
+    .focus-svg path {
+      fill: none;
+      stroke: var(--line);
+      stroke-width: 2.2;
+      stroke-linecap: round;
+    }
+    .focus-svg.dense {
+      width: 170px;
+      height: 58px;
+      top: -30px;
+    }
+    .focus-svg.dense path { stroke: var(--line-dense); }
+    .note {
+      margin-top: 16px;
+      padding: 10px 14px;
+      border: 1px dashed #cbd5e1;
+      border-radius: 10px;
+      color: #334155;
+      background: #fff;
+      font-size: 16px;
+    }
+    .span-2 { grid-column: 1 / -1; min-height: 170px; }
+    .mini { min-height: 190px; }
+  </style>
+</head>
+<body>
+  <div class="wrap">
+    <h1>二、知识点掌握聚类视图(PDF场景复刻)</h1>
+    <div class="legend">
+      <span class="dot" style="background:var(--dot-mastered)"></span>已掌握
+      <span class="dot" style="background:var(--dot-weak)"></span>薄弱
+      <span class="dot" style="background:var(--dot-beginner)"></span>未入门
+      <span class="dot" style="background:var(--dot-unlearned)"></span>未学习
+      按“模块 -> 子模块 -> 知识点”聚类展示
+    </div>
+
+    <div class="grid">
+      <section class="card">
+        <h2>函数</h2>
+        <div class="group">
+          <div class="group-title">二次函数</div>
+          <div class="points">
+            <span class="point weak"></span><span class="point weak"></span><span class="point beginner"></span><span class="point beginner"></span><span class="point beginner"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">锐角三角函数</div>
+          <div class="points">
+            <span class="point beginner"></span>
+            <span class="point beginner focus">
+              <svg class="focus-svg" viewBox="0 0 150 64" aria-hidden="true">
+                <!-- 普通场景:起点锚定点中心(8,40),终点进入标签内侧(136,40) -->
+                <path d="M8,40 C16,40 22,26 40,20 C84,16 114,34 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag">三角函数的应用</span>
+            </span>
+            <span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">反比例函数</div>
+          <div class="points">
+            <span class="point mastered"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card">
+        <h2>相似与勾股</h2>
+        <div class="group">
+          <div class="group-title">相似三角形判定</div>
+          <div class="points">
+            <span class="point mastered"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">相似三角形性质</div>
+          <div class="points">
+            <span class="point weak"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">勾股定理与直角三角形</div>
+          <div class="points">
+            <span class="point beginner focus">
+              <svg class="focus-svg" viewBox="0 0 150 64" aria-hidden="true">
+                <path d="M8,40 C16,40 22,26 38,20 C80,16 110,34 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag">直角三角形性质</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">相似与勾股在压轴题中的整合</div>
+          <div class="points">
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card span-2 mini">
+        <h2>图形变化与度量 / 图形度量</h2>
+        <div class="group">
+          <div class="group-title">立体几何度量(表面积与体积)</div>
+          <div class="points">
+            <span class="point beginner focus">
+              <svg class="focus-svg bottom" viewBox="0 0 150 64" aria-hidden="true">
+                <!-- 跨列底部场景:弧线更高,避免贴近点阵 -->
+                <path d="M8,40 C16,40 22,24 40,18 C82,15 112,33 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag bottom">立体几何展开图</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card">
+        <h2>方程与不等式</h2>
+        <div class="group">
+          <div class="group-title">一元二次方程</div>
+          <div class="points">
+            <span class="point beginner"></span><span class="point beginner"></span><span class="point beginner"></span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">分式方程</div>
+          <div class="points">
+            <span class="point beginner focus">
+              <svg class="focus-svg" viewBox="0 0 150 64" aria-hidden="true">
+                <path d="M8,40 C16,40 22,26 38,20 C80,16 110,34 130,39 L136,40" />
+              </svg>
+              <span class="focus-tag">分式方程的应用</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+
+      <section class="card">
+        <h2>数与代数</h2>
+        <div class="group">
+          <div class="group-title">数的认识与运算</div>
+          <div class="points">
+            <span class="point beginner"></span><span class="point beginner"></span><span class="point weak"></span>
+            <span class="point beginner focus">
+              <svg class="focus-svg dense" viewBox="0 0 150 64" aria-hidden="true">
+                <!-- 密集场景:前段快速抬升 + 终点内插,避免擦到后续灰块 -->
+                <path d="M8,40 C14,40 20,26 34,22 C78,18 124,26 154,34 L162,36" />
+              </svg>
+              <span class="focus-tag dense">幂与指数</span>
+            </span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+            <span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+        <div class="group">
+          <div class="group-title">代数式与整式运算</div>
+          <div class="points">
+            <span class="point beginner"></span><span class="point beginner"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span><span class="point unlearned"></span>
+          </div>
+        </div>
+      </section>
+    </div>
+
+    <div class="note">
+      这版覆盖了 PDF 中常见情况:长标题换行、密集点阵、上下卡片、多个关注点位置、右侧标签区。你确认这页视觉后,我再同步回正式模板。
+    </div>
+  </div>
+</body>
+</html>
+

+ 54 - 59
resources/views/components/exam/paper-body.blade.php

@@ -156,37 +156,10 @@
                     $stemLine = trim($stemMatch[1]);
                 }
             }
-            // 将题干中的空括号/下划线替换为短波浪线;如无占位符,则在末尾追加短波浪线
-            $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-            // 【修复】扩展下划线转换规则,支持LaTeX格式和多种占位符
-            $renderedStem = $stemLine;
-            // 先处理LaTeX格式的underline命令
-            $renderedStem = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedStem);
-            $renderedStem = preg_replace('/\\\qquad+/', $blankSpan, $renderedStem);
-            // 【修复】在处理填空占位符时,保护LaTeX公式不被破坏
-            // 先标记LaTeX公式区域
-            $latexPlaceholders = [];
-            $counter = 0;
-            $renderedStem = preg_replace_callback('/\$[^$]+\$/u', function($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
-                $placeholder = '<<<LATEX_' . $counter . '>>>';
-                $latexPlaceholders[$placeholder] = $matches[0];
-                $counter++;
-                return $placeholder;
-            }, $renderedStem);
-
-            // 现在处理普通占位符(不会破坏LaTeX公式)
-            $renderedStem = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedStem);
-
-            // 恢复LaTeX公式(并进行HTML实体编码防止被浏览器解析)
-            foreach ($latexPlaceholders as $placeholder => $latexContent) {
-                $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                $renderedStem = str_replace($placeholder, $encodedLatex, $renderedStem);
-            }
-
-            // 如果没有占位符,在末尾添加
-            if ($renderedStem === $stemLine) {
-                $renderedStem .= ' ' . $blankSpan;
-            }
+            // 选择题只做占位符归一,不再兜底追加下划线,避免出现“( )+下划线”重复。
+            [$renderedStem] = \App\Support\BlankPlaceholderRenderer::replaceToBlankSpan($stemLine, null, true, false);
+            // 选择题:句尾不保留句号。
+            $renderedStem = \App\Support\BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedStem, 'remove');
             $renderedStem = $mathProcessed ? $renderedStem : \App\Services\MathFormulaProcessor::processFormulas($renderedStem);
             $qTemIdSelect = $interactiveTemSelect ? abs((int) ($q->id ?? 0)) : 0;
             if ($interactiveTemSelect && $interactiveTemMultiSelect) {
@@ -247,6 +220,18 @@
                         );
                         $optionsClass = $layoutMeta['class'];
                         $layoutDesc = $layoutMeta['layout'];
+                        $hasImageOptionInQuestion = false;
+                        foreach ($options as $optRaw) {
+                            if (preg_match('/<(img|image|svg)\\b|data:image\\//i', (string) $optRaw) === 1) {
+                                $hasImageOptionInQuestion = true;
+                                break;
+                            }
+                        }
+                        if ($hasImageOptionInQuestion) {
+                            // 简化规则:图片选项固定四列同一行展示
+                            $optionsClass = 'options-grid-4';
+                            $layoutDesc = '4列布局(图片选项固定)';
+                        }
 
                         \Illuminate\Support\Facades\Log::debug('选择题布局决策', [
                             'question_number' => $questionNumber,
@@ -254,6 +239,7 @@
                             'opt_count' => $layoutMeta['opt_count'],
                             'max_length' => $layoutMeta['max_length'],
                             'has_complex_formula' => $layoutMeta['has_complex_formula'],
+                            'has_image_option' => $hasImageOptionInQuestion,
                             'selected_class' => $optionsClass,
                             'layout' => $layoutDesc
                         ]);
@@ -287,6 +273,13 @@
                                     $encodedOpt = htmlspecialchars($normalizedOpt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                                     $renderedOpt = \App\Services\MathFormulaProcessor::processFormulas($encodedOpt);
                                 }
+                                // 仅针对“选项图片”覆盖公式处理器默认的题干尺寸,避免四列布局被 220px 宽图撑出边界
+                                $renderedOpt = preg_replace('/max-width\s*:\s*220px\s*;?/iu', 'max-width:100%;', (string) $renderedOpt);
+                                $renderedOpt = preg_replace('/max-height\s*:\s*60mm\s*;?/iu', 'max-height:28mm;', (string) $renderedOpt);
+                                // 标记选项内图片,供 PDF 全局宽图放大逻辑识别并跳过
+                                $renderedOpt = preg_replace('/<img\b(?![^>]*\bdata-option-image=)/iu', '<img data-option-image="1"', (string) $renderedOpt);
+                                // 兼容未来选项直接使用 <svg> 的场景,同样打标走选项专用规则
+                                $renderedOpt = preg_replace('/<svg\b(?![^>]*\bdata-option-image=)/iu', '<svg data-option-image="1"', (string) $renderedOpt);
 
                                 // 细粒度控制:短选项(如 1/2、-1/3、x、-x)尽量单行展示,长选项允许换行
                                 $rawOptText = html_entity_decode(strip_tags((string) $opt), ENT_QUOTES | ENT_HTML5, 'UTF-8');
@@ -294,7 +287,8 @@
                                 $rawOptLen = mb_strlen((string) $rawOptText, 'UTF-8');
                                 $isShortOption = $rawOptLen <= 8;
                             @endphp
-                            <div class="option option-compact">
+                            @php $hasImageOption = preg_match('/<(img|image|svg)\\b|data:image\\//i', (string) $renderedOpt) === 1; @endphp
+                            <div class="option option-compact {{ $hasImageOption ? 'option-with-image' : '' }}">
                                 <strong>{{ $label }}.</strong>
                                 <span class="option-value {{ $isShortOption ? 'option-short' : 'option-long' }}">{!! $renderedOpt !!}</span>
                             </div>
@@ -350,34 +344,17 @@
         @php
             // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
             $questionNumber = $q->question_number ?? (count($choiceQuestions) + $index + 1);
-            $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-            // 【修复】扩展下划线转换规则,支持LaTeX格式和多种占位符
-            $renderedContent = $q->content;
-            // 【修复】在处理填空占位符时,保护LaTeX公式不被破坏
-            // 先标记LaTeX公式区域(支持包含反斜杠和花括号的LaTeX命令)
-            $latexPlaceholders = [];
-            $counter = 0;
-            $renderedContent = preg_replace_callback('/\$(?:[^\$]|\\.)*\$/u', function($matches) use (&$latexPlaceholders, &$counter, $blankSpan) {
-                $placeholder = '<<<LATEX_FILL_' . $counter . '>>>';
-                $latexPlaceholders[$placeholder] = $matches[0];
-                $counter++;
-                return $placeholder;
-            }, $renderedContent);
-
-            // 现在处理普通占位符(不会破坏LaTeX公式)
-            $renderedContent = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedContent);
-
-            // 恢复LaTeX公式(并进行HTML实体编码防止被浏览器解析)
-            foreach ($latexPlaceholders as $placeholder => $latexContent) {
-                $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                $renderedContent = str_replace($placeholder, $encodedLatex, $renderedContent);
-            }
-
-            // 如果没有占位符且内容没有变化,在末尾添加
-            // 但要检查是否已经有填空占位符(如\underline{\qquad})
-            if ($renderedContent === $q->content && !preg_match('/\\\\underline|\\\\qquad|(\s*)|\(\s*\)/', $renderedContent)) {
+            $blankSpan = \App\Support\BlankPlaceholderRenderer::defaultBlankSpan();
+            [$renderedContent, $hasPlaceholders] = \App\Support\BlankPlaceholderRenderer::replaceToBlankSpan((string) $q->content, $blankSpan, false, false);
+            // 填空题保留兜底:题干无任何占位时,在末尾补一个标准空位。
+            if (!$hasPlaceholders) {
                 $renderedContent .= ' ' . $blankSpan;
             }
+            // 填空题:句尾统一为实心小圆点(英文句点)。
+            $renderedContent = \App\Support\BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedContent, 'dot');
+            // 填空题:若末尾是“句号 + 括注说明”(后可跟图片),将该句号统一为英文小点。
+            $renderedContent = \App\Support\BlankPlaceholderRenderer::normalizePeriodBeforeTrailingParentheticalNote($renderedContent, '.');
+            $renderedContent = \App\Support\BlankPlaceholderRenderer::appendTerminalPunctuationIfMissing($renderedContent, '.');
             $renderedContent = $mathProcessed ? $renderedContent : \App\Services\MathFormulaProcessor::processFormulas($renderedContent);
             $qTemIdSelect = $interactiveTemSelect ? abs((int) ($q->id ?? 0)) : 0;
             if ($interactiveTemSelect && $interactiveTemMultiSelect) {
@@ -481,6 +458,24 @@
             } else {
                 $qTemSelected = $interactiveTemSelect && (int) ($selectedTemIdForSelect ?? 0) === $qTemIdSelect;
             }
+            // 解答题小题排版优化(仅在小题编号语境下换行,避免误伤 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
         @if($interactiveTemSelect)
             @if($interactiveTemMultiSelect)
@@ -523,7 +518,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)

+ 1286 - 0
resources/views/exam-analysis/pdf-report-v3.blade.php

@@ -0,0 +1,1286 @@
+@php
+    $v3 = $v3 ?? [];
+    $summary = $v3['summary'] ?? [];
+    $radar = $v3['radar'] ?? [];
+    $modules = $v3['modules'] ?? [];
+    $paths = $v3['paths'] ?? ['keep' => [], 'boost' => [], 'key' => []];
+
+    $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
+    preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
+    $reportCode = $matches[1] ?? preg_replace('/[^0-9]/', '', (string) $rawPaperId);
+    $generateDateTime = now()->format('Y年m月d日 H:i:s');
+
+    $scoreObtained = $summary['score_obtained'] ?? null;
+    $scoreTotal = $summary['score_total'] ?? null;
+    $scoreRate = $summary['score_rate'] ?? null;
+    $averageMastery = $summary['average_mastery'] ?? null;
+    $examHitKpSet = array_fill_keys(array_map('strval', $exam_hit_kp_codes ?? []), true);
+    $difficultySummary = $summary['difficulty'] ?? [];
+    $comparisonSummary = $summary['comparison'] ?? [];
+    $overallLabelDetail = $summary['overall_label_detail'] ?? [];
+    $historySummary = $comparisonSummary['history'] ?? [];
+    $peerSummary = $comparisonSummary['peers'] ?? [];
+    $overallScore = isset($overallLabelDetail['composite_score']) ? (float) $overallLabelDetail['composite_score'] : null;
+    $overallGrade = (string) ($overallLabelDetail['grade'] ?? 'D');
+    $currentPart = (float) ($overallLabelDetail['current_score'] ?? 0);
+    $historyPart = (float) ($overallLabelDetail['history_score'] ?? 0);
+    $peerPart = (float) ($overallLabelDetail['peer_score'] ?? 0);
+    $adjustPart = (float) ($overallLabelDetail['difficulty_adjust'] ?? 0);
+    $compositeFormulaResult = (0.50 * $currentPart) + (0.25 * $historyPart) + (0.25 * $peerPart) + $adjustPart;
+    $overallBadge = function (string $grade): array {
+        return match ($grade) {
+            'S' => ['bg' => '#f5f3ff', 'border' => '#6d28d9', 'text' => '#6d28d9', 'class' => 'badge-s'],
+            'A' => ['bg' => '#ecfdf3', 'border' => '#22c55e', 'text' => '#166534', 'class' => 'badge-excellent'],
+            'B' => ['bg' => '#eff6ff', 'border' => '#3b82f6', 'text' => '#1d4ed8', 'class' => 'badge-good'],
+            'C' => ['bg' => '#fff7ed', 'border' => '#f59e0b', 'text' => '#b45309', 'class' => 'badge-average'],
+            default => ['bg' => '#fef2f2', 'border' => '#ef4444', 'text' => '#b91c1c', 'class' => 'badge-weak'],
+        };
+    };
+    $overallVisual = $overallBadge((string) $overallGrade);
+    $trendVisual = function (string $trend): array {
+        return match ($trend) {
+            '显著提升' => ['icon' => '▲', 'color' => '#16a34a'],
+            '小幅提升' => ['icon' => '↗', 'color' => '#0ea5e9'],
+            '基本持平' => ['icon' => '•', 'color' => '#64748b'],
+            '小幅回落' => ['icon' => '↘', 'color' => '#f59e0b'],
+            '明显回落' => ['icon' => '▼', 'color' => '#ef4444'],
+            default => ['icon' => '•', 'color' => '#64748b'],
+        };
+    };
+
+    $statusColor = function (string $status): string {
+        return match ($status) {
+            '已掌握' => '#16a34a',
+            '薄弱' => '#f59e0b',
+            '未入门' => '#ef4444',
+            default => '#64748b',
+        };
+    };
+
+    $analysisWrongMap = [];
+    foreach (($analysis_data['question_analysis'] ?? []) as $qa) {
+        $qid = $qa['question_bank_id'] ?? $qa['question_id'] ?? null;
+        if ($qid === null || $qid === '') {
+            continue;
+        }
+        $rawCorrect = $qa['is_correct'] ?? null;
+        $isWrongFromAnalysis = false;
+        if (is_array($rawCorrect)) {
+            $isWrongFromAnalysis = in_array(0, $rawCorrect, true);
+        } elseif ($rawCorrect !== null) {
+            $isWrongFromAnalysis = !boolval($rawCorrect);
+        }
+        if ($isWrongFromAnalysis) {
+            $analysisWrongMap[(string) $qid] = true;
+        }
+    }
+
+    $wrongQuestions = [];
+    foreach (($questions ?? []) as $qItem) {
+        $isCorrectProbe = $qItem['is_correct'] ?? null;
+        $studentAnswerProbe = $qItem['student_answer'] ?? null;
+        $correctAnswerProbe = $qItem['answer'] ?? ($qItem['correct_answer'] ?? null);
+        if ($isCorrectProbe === null && !empty($studentAnswerProbe) && !empty($correctAnswerProbe)) {
+            $isCorrectProbe = (trim((string) $studentAnswerProbe) === trim((string) $correctAnswerProbe)) ? 1 : 0;
+        }
+        $normalizedCorrect = $isCorrectProbe;
+        if ($isCorrectProbe !== null) {
+            $normalizedCorrect = is_bool($isCorrectProbe) ? ($isCorrectProbe ? 1 : 0) : intval($isCorrectProbe);
+        }
+        $qidProbe = (string) ($qItem['question_bank_id'] ?? $qItem['question_id'] ?? '');
+        $isWrongByAnalysis = ($qidProbe !== '' && isset($analysisWrongMap[$qidProbe]));
+        if ($normalizedCorrect === 0 || $isWrongByAnalysis) {
+            $wrongQuestions[] = $qItem;
+        }
+    }
+
+    $kpStats = [];
+    foreach (($questions ?? []) as $qItem) {
+        $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
+        $kpName = $kpName === '' ? '未标注知识点' : $kpName;
+        if (!isset($kpStats[$kpName])) {
+            $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
+        }
+        $kpStats[$kpName]['total']++;
+    }
+    foreach ($wrongQuestions as $qItem) {
+        $kpName = trim((string) ($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
+        $kpName = $kpName === '' ? '未标注知识点' : $kpName;
+        if (!isset($kpStats[$kpName])) {
+            $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
+        }
+        $kpStats[$kpName]['wrong']++;
+    }
+    $kpWrongStats = [];
+    foreach ($kpStats as $kpName => $stat) {
+        if (($stat['wrong'] ?? 0) <= 0) {
+            continue;
+        }
+        $total = max(1, intval($stat['total'] ?? 0));
+        $wrong = intval($stat['wrong'] ?? 0);
+        $kpWrongStats[] = [
+            'kp_name' => $kpName,
+            'wrong' => $wrong,
+            'total' => $total,
+            'rate' => $wrong / $total,
+        ];
+    }
+    usort($kpWrongStats, function ($a, $b) {
+        if ($a['rate'] === $b['rate']) {
+            return $b['wrong'] <=> $a['wrong'];
+        }
+        return $b['rate'] <=> $a['rate'];
+    });
+
+    $pcMasteryPercent = function ($mastery): ?int {
+        if ($mastery === null) {
+            return null;
+        }
+        // 与 PC 保持一致:API 先保留 2 位小数,前端再 Math.round 到 0-100。
+        return (int) round(round((float) $mastery, 2) * 100);
+    };
+    $formatMasteryPct = function ($mastery) use ($pcMasteryPercent): string {
+        $percent = $pcMasteryPercent($mastery);
+        return $percent === null ? '-' : ($percent . '%');
+    };
+    $childMasteryStatus = function ($mastery) use ($pcMasteryPercent): string {
+        $m = $pcMasteryPercent($mastery);
+        if ($m === null) {
+            return '未学习';
+        }
+        if ($m >= 85) {
+            return '已掌握';
+        }
+        if ($m >= 60) {
+            return '薄弱';
+        }
+        return '未入门';
+    };
+    $childStatusColor = function ($status): string {
+        return match ($status) {
+            '已掌握' => '#52c41a',
+            '薄弱' => '#faad14',
+            '未入门' => '#f5222d',
+            default => '#d9d9d9',
+        };
+    };
+    $calcStats = function (array $points): array {
+        $total = count($points);
+        $learned = 0;
+        $mastered = 0;
+        $weak = 0;
+        $beginner = 0;
+        $unlearned = 0;
+        $hit = 0;
+        foreach ($points as $p) {
+            if (($p['mastery_level'] ?? null) !== null) {
+                $learned++;
+            }
+            if (! empty($p['is_hit'])) {
+                $hit++;
+            }
+            $status = (string) ($p['status'] ?? '未学习');
+            if ($status === '已掌握') {
+                $mastered++;
+            } elseif ($status === '薄弱') {
+                $weak++;
+            } elseif ($status === '未入门') {
+                $beginner++;
+            } else {
+                $unlearned++;
+            }
+        }
+        return [
+            'total' => $total,
+            'learned' => $learned,
+            'mastered' => $mastered,
+            'weak' => $weak,
+            'beginner' => $beginner,
+            'unlearned' => $unlearned,
+            'hit' => $hit,
+        ];
+    };
+
+    $clusterCards = [];
+    $allStagePoints = [];
+    foreach ($radar as $moduleItem) {
+        $children = is_array($moduleItem['children'] ?? null) ? $moduleItem['children'] : [];
+        $greatMap = [];
+        foreach ($children as $child) {
+            $greatKey = trim((string) ($child['great_grand_parent_name'] ?? ''));
+            $greatKey = $greatKey !== '' ? $greatKey : '未分组';
+            $grandKey = trim((string) ($child['grand_parent_name'] ?? ''));
+            $grandKey = $grandKey !== '' ? $grandKey : '未分组';
+            $parentName = trim((string) ($child['parent_name'] ?? ''));
+            if ($parentName === '') {
+                $parentCode = trim((string) ($child['parent_code'] ?? ''));
+                $parentName = $parentCode !== '' ? $parentCode : '未分组';
+            }
+            $childMasteryLevel = isset($child['mastery_level']) ? (float) $child['mastery_level'] : null;
+            $status = $childMasteryStatus($childMasteryLevel);
+            if (!isset($greatMap[$greatKey])) {
+                $greatMap[$greatKey] = [];
+            }
+            if (!isset($greatMap[$greatKey][$grandKey])) {
+                $greatMap[$greatKey][$grandKey] = [];
+            }
+            if (!isset($greatMap[$greatKey][$grandKey][$parentName])) {
+                $greatMap[$greatKey][$grandKey][$parentName] = [];
+            }
+            $childCode = (string) ($child['code'] ?? '');
+            $childParentCode = (string) ($child['parent_code'] ?? '');
+            $isHit = !empty($child['is_hit'])
+                || ($childCode !== '' && isset($examHitKpSet[$childCode]))
+                || ($childParentCode !== '' && isset($examHitKpSet[$childParentCode]));
+            $greatMap[$greatKey][$grandKey][$parentName][] = [
+                'code' => $childCode,
+                'name' => (string) ($child['name'] ?? '未命名知识点'),
+                'parent_code' => $childParentCode,
+                'path' => (string) ($child['path'] ?? ''),
+                'mastery_level' => $childMasteryLevel,
+                'change' => isset($child['change']) ? (float) $child['change'] : null,
+                'status' => $status,
+                'color' => $childStatusColor($status),
+                'is_hit' => $isHit,
+            ];
+            $allStagePoints[] = [
+                'code' => $childCode,
+                'name' => (string) ($child['name'] ?? '未命名知识点'),
+                'parent_code' => $childParentCode,
+                'mastery_level' => $childMasteryLevel,
+                'status' => $status,
+                'change' => isset($child['change']) ? (float) $child['change'] : null,
+                'is_hit' => $isHit,
+            ];
+        }
+
+        $greatGroups = [];
+        foreach ($greatMap as $greatName => $grandMap) {
+            $grandGroups = [];
+            foreach ($grandMap as $grandName => $parentMap) {
+                $parentGroups = [];
+                foreach ($parentMap as $parentName => $points) {
+                    usort($points, function ($a, $b) {
+                        $am = $a['mastery_level'] ?? -1;
+                        $bm = $b['mastery_level'] ?? -1;
+                        if ($am === $bm) {
+                            return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
+                        }
+                        return $bm <=> $am;
+                    });
+                    $parentGroups[] = [
+                        'parent_name' => $parentName,
+                        'points' => $points,
+                        'stats' => $calcStats($points),
+                    ];
+                }
+                // 子模块级过滤:整行没有任何掌握度数字则不显示
+                $parentGroups = array_values(array_filter($parentGroups, function ($pg) {
+                    return (($pg['stats']['learned'] ?? 0) > 0) || (($pg['stats']['hit'] ?? 0) > 0);
+                }));
+                if (empty($parentGroups)) {
+                    continue;
+                }
+                usort($parentGroups, function ($a, $b) {
+                    $sa = $a['stats'];
+                    $sb = $b['stats'];
+                    return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
+                });
+                $allGrandPoints = [];
+                foreach ($parentGroups as $pg) {
+                    $allGrandPoints = array_merge($allGrandPoints, $pg['points']);
+                }
+                $grandGroups[] = [
+                    'grand_name' => $grandName,
+                    'parent_groups' => $parentGroups,
+                    'stats' => $calcStats($allGrandPoints),
+                ];
+            }
+            // 大块级过滤:整块没有任何掌握度数字则不显示
+            $grandGroups = array_values(array_filter($grandGroups, function ($gg) {
+                return (($gg['stats']['learned'] ?? 0) > 0) || (($gg['stats']['hit'] ?? 0) > 0);
+            }));
+            if (empty($grandGroups)) {
+                continue;
+            }
+            usort($grandGroups, function ($a, $b) {
+                $sa = $a['stats'];
+                $sb = $b['stats'];
+                return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
+            });
+            $allGreatPoints = [];
+            foreach ($grandGroups as $gg) {
+                foreach ($gg['parent_groups'] as $pg) {
+                    $allGreatPoints = array_merge($allGreatPoints, $pg['points']);
+                }
+            }
+            $greatGroups[] = [
+                'great_name' => $greatName,
+                'grand_groups' => $grandGroups,
+                'stats' => $calcStats($allGreatPoints),
+            ];
+        }
+        usort($greatGroups, function ($a, $b) {
+            $sa = $a['stats'];
+            $sb = $b['stats'];
+            return ($sb['learned'] <=> $sa['learned']) ?: ($sb['total'] <=> $sa['total']);
+        });
+
+        // 严格参考 math.client-pc:扁平化为“grand 层卡片”(展示大块)
+        foreach ($greatGroups as $great) {
+            foreach (($great['grand_groups'] ?? []) as $grand) {
+                $gStats = $grand['stats'] ?? ['learned' => 0, 'total' => 0];
+                $clusterCards[] = [
+                    'module_name' => (string) ($moduleItem['name'] ?? '未分组'),
+                    'great_name' => $great['great_name'] ?? '未分组',
+                    'grand_name' => $grand['grand_name'] ?? '未分组',
+                    'parent_groups' => $grand['parent_groups'] ?? [],
+                    'stats' => $gStats,
+                ];
+            }
+        }
+    }
+    usort($clusterCards, function ($a, $b) {
+        $sa = $a['stats'] ?? ['learned' => 0, 'total' => 0];
+        $sb = $b['stats'] ?? ['learned' => 0, 'total' => 0];
+        return (($sb['learned'] ?? 0) <=> ($sa['learned'] ?? 0))
+            ?: (($sb['total'] ?? 0) <=> ($sa['total'] ?? 0));
+    });
+    $kpStatsTotal = [
+        'total' => count($allStagePoints),
+        'mastered' => 0,
+        'weak' => 0,
+        'beginner' => 0,
+        'unlearned' => 0,
+    ];
+    foreach ($allStagePoints as $p) {
+        $st = (string) ($p['status'] ?? '未学习');
+        if ($st === '已掌握') {
+            $kpStatsTotal['mastered']++;
+        } elseif ($st === '薄弱') {
+            $kpStatsTotal['weak']++;
+        } elseif ($st === '未入门') {
+            $kpStatsTotal['beginner']++;
+        } else {
+            $kpStatsTotal['unlearned']++;
+        }
+    }
+    $moduleRowsWithStatus = array_values(array_filter($modules, function ($m) {
+        $status = trim((string) ($m['status'] ?? ''));
+        $masteryLevel = $m['mastery_level'] ?? null;
+        $questionCount = (int) ($m['question_count'] ?? 0);
+        if ($masteryLevel !== null) {
+            return true;
+        }
+        return $questionCount > 0 && $status !== '' && ! in_array($status, ['暂无', '-', '未涉及', '未学习'], true);
+    }));
+    $pathTagByModuleName = [];
+    foreach (['keep' => '保分不错', 'boost' => '需要加强', 'key' => '优先加强'] as $bucket => $tagName) {
+        foreach (($paths[$bucket] ?? []) as $item) {
+            $n = trim((string) ($item['name'] ?? ''));
+            if ($n === '') {
+                continue;
+            }
+            $pathTagByModuleName[$n] = $tagName;
+        }
+    }
+    $globalPathTagByMastery = function ($mastery) use ($pcMasteryPercent): string {
+        if ($mastery === null || ! is_numeric($mastery)) {
+            return '待观察';
+        }
+        $m = $pcMasteryPercent($mastery);
+        if ($m >= 85) {
+            return '保分不错';
+        }
+        if ($m >= 60) {
+            return '需要加强';
+        }
+        return '优先加强';
+    };
+    $overallPathTag = function (string $tag): string {
+        return match ($tag) {
+            '优先加强' => '整体优先',
+            '需要加强' => '整体加强',
+            '保分不错' => '整体巩固',
+            default => $tag,
+        };
+    };
+    $impactedModules = array_values(array_filter($moduleRowsWithStatus, function ($m) {
+        return ((int) ($m['question_count'] ?? 0)) > 0;
+    }));
+    $radarModuleMap = [];
+    foreach ($radar as $moduleItem) {
+        $code = (string) ($moduleItem['code'] ?? '');
+        if ($code !== '') {
+            $radarModuleMap[$code] = $moduleItem;
+        }
+    }
+    $moduleKpSuggestions = [];
+    foreach ($moduleRowsWithStatus as $m) {
+        $moduleCode = (string) ($m['module_code'] ?? '');
+        $moduleName = (string) ($m['module_name'] ?? '-');
+        $moduleChildren = $radarModuleMap[$moduleCode]['children'] ?? [];
+        if (! is_array($moduleChildren) || empty($moduleChildren)) {
+            continue;
+        }
+        $moduleHitCandidates = [];
+        foreach (($mastery['items'] ?? []) as $item) {
+            $hitCode = trim((string) ($item['kp_code'] ?? $item['code'] ?? ''));
+            if ($hitCode === '') {
+                continue;
+            }
+            if (! empty($examHitKpSet) && ! isset($examHitKpSet[$hitCode])) {
+                continue;
+            }
+            $hitLevel = $item['mastery_level'] ?? null;
+            if ($hitLevel === null || ! is_numeric($hitLevel)) {
+                continue;
+            }
+            $matchedChild = null;
+            foreach ($moduleChildren as $child) {
+                $childCode = trim((string) ($child['code'] ?? ''));
+                $parentCode = trim((string) ($child['parent_code'] ?? ''));
+                if ($childCode === $hitCode || $parentCode === $hitCode) {
+                    $matchedChild = $child;
+                    break;
+                }
+            }
+            if (! is_array($matchedChild)) {
+                continue;
+            }
+            $moduleHitCandidates[$hitCode] = [
+                'code' => $hitCode,
+                'name' => (string) ($item['kp_name'] ?? $item['name'] ?? ($matchedChild['parent_name'] ?? $matchedChild['name'] ?? $hitCode)),
+                'parent_code' => (string) ($matchedChild['parent_code'] ?? ''),
+                'parent_name' => (string) ($matchedChild['parent_name'] ?? ''),
+                'grand_parent_name' => (string) ($matchedChild['grand_parent_name'] ?? ''),
+                'mastery_level' => (float) $hitLevel,
+                'is_hit' => true,
+            ];
+        }
+        $startedByCode = $moduleHitCandidates;
+        foreach (array_values(array_filter($moduleChildren, function ($c) {
+            return isset($c['mastery_level']) && $c['mastery_level'] !== null;
+        })) as $child) {
+            $childCode = trim((string) ($child['code'] ?? ''));
+            if ($childCode !== '' && ! isset($startedByCode[$childCode])) {
+                $startedByCode[$childCode] = $child;
+            }
+        }
+        $started = array_values($startedByCode);
+        usort($started, function ($a, $b) {
+            $am = (float) ($a['mastery_level'] ?? 0);
+            $bm = (float) ($b['mastery_level'] ?? 0);
+            if ($am === $bm) {
+                $ah = !empty($a['is_hit']) ? 0 : 1;
+                $bh = !empty($b['is_hit']) ? 0 : 1;
+                if ($ah !== $bh) {
+                    return $ah <=> $bh;
+                }
+                return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
+            }
+            return $am <=> $bm;
+        });
+
+        $weakest = null;
+        if (! empty($started)) {
+            $lowestStarted = $started[0];
+            $lowestStartedLevel = isset($lowestStarted['mastery_level']) ? (float) $lowestStarted['mastery_level'] : null;
+            if ($lowestStartedLevel !== null && ($pcMasteryPercent($lowestStartedLevel) ?? 0) < 85) {
+                // 规则1:已开始学习中掌握度最低
+                $weakest = $lowestStarted;
+            } else {
+                // 规则2:若已开始学习均达标(>=85%),取“最近的未学习”
+                $unlearned = array_values(array_filter($moduleChildren, function ($c) {
+                    return !isset($c['mastery_level']) || $c['mastery_level'] === null;
+                }));
+                if (! empty($unlearned)) {
+                    $anchorParent = (string) ($lowestStarted['parent_name'] ?? '');
+                    $anchorGrand = (string) ($lowestStarted['grand_parent_name'] ?? '');
+                    usort($unlearned, function ($a, $b) use ($anchorParent, $anchorGrand) {
+                        $score = function ($node) use ($anchorParent, $anchorGrand) {
+                            $parent = (string) ($node['parent_name'] ?? '');
+                            $grand = (string) ($node['grand_parent_name'] ?? '');
+                            if ($anchorParent !== '' && $parent === $anchorParent) {
+                                return 0;
+                            }
+                            if ($anchorGrand !== '' && $grand === $anchorGrand) {
+                                return 1;
+                            }
+                            return 2;
+                        };
+                        $sa = $score($a);
+                        $sb = $score($b);
+                        if ($sa === $sb) {
+                            return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
+                        }
+                        return $sa <=> $sb;
+                    });
+                    $weakest = $unlearned[0];
+                }
+            }
+        } else {
+            // 没有已开始学习数据时,回退到模块内任一未学习点
+            $unlearned = array_values(array_filter($moduleChildren, function ($c) {
+                return !isset($c['mastery_level']) || $c['mastery_level'] === null;
+            }));
+            if (! empty($unlearned)) {
+                usort($unlearned, fn ($a, $b) => strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')));
+                $weakest = $unlearned[0];
+            }
+        }
+        if (! is_array($weakest)) {
+            $moduleKpSuggestions[] = [
+                'module_name' => $moduleName,
+                'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
+                'kp_name' => '',
+                'kp_code' => '',
+                'mastery_level' => null,
+                'status' => '当前模块暂无需额外关注知识点',
+                'is_empty' => true,
+            ];
+            continue;
+        }
+        $kpName = (string) ($weakest['name'] ?? '');
+        if ($kpName === '') {
+            continue;
+        }
+        $kpCode = (string) ($weakest['code'] ?? '');
+        $moduleKpSuggestions[] = [
+            'module_name' => $moduleName,
+            'path_tag' => $pathTagByModuleName[$moduleName] ?? '待观察',
+            'kp_name' => $kpName,
+            'kp_code' => $kpCode,
+            'mastery_level' => $weakest['mastery_level'] ?? null,
+            'status' => $childMasteryStatus($weakest['mastery_level'] ?? null),
+            'is_empty' => false,
+        ];
+    }
+    $moduleSuggestionByName = [];
+    foreach ($moduleKpSuggestions as $sug) {
+        $name = trim((string) ($sug['module_name'] ?? ''));
+        if ($name !== '') {
+            $moduleSuggestionByName[$name] = $sug;
+        }
+    }
+    $focusMarkerByCode = [];
+    foreach ($moduleKpSuggestions as $sug) {
+        if (! empty($sug['is_empty'])) {
+            continue;
+        }
+        $code = trim((string) ($sug['kp_code'] ?? ''));
+        $name = trim((string) ($sug['kp_name'] ?? ''));
+        if ($code === '' || $name === '') {
+            continue;
+        }
+        $focusMarkerByCode[$code] = [
+            'name' => $name,
+            'module_name' => (string) ($sug['module_name'] ?? ''),
+            'mastery_level' => $sug['mastery_level'] ?? null,
+        ];
+    }
+    $renderedFocusMarkerCodes = [];
+    $kpChangeItems = [];
+    foreach (($mastery['items'] ?? []) as $item) {
+        $code = trim((string) ($item['kp_code'] ?? $item['code'] ?? ''));
+        if ($code !== '' && ! empty($examHitKpSet) && ! isset($examHitKpSet[$code])) {
+            continue;
+        }
+        $level = $item['mastery_level'] ?? null;
+        if ($level === null || ! is_numeric($level)) {
+            continue;
+        }
+        $change = $item['mastery_change'] ?? $item['change'] ?? 0.0;
+        $kpChangeItems[] = [
+            'code' => $code,
+            'name' => (string) ($item['kp_name'] ?? $item['name'] ?? ($code !== '' ? $code : '-')),
+            'mastery_level' => (float) $level,
+            'change' => is_numeric($change) ? (float) $change : 0.0,
+            'status' => $childMasteryStatus((float) $level),
+            'is_hit' => true,
+        ];
+    }
+    usort($kpChangeItems, function ($a, $b) {
+        return abs((float) ($b['change'] ?? 0)) <=> abs((float) ($a['change'] ?? 0));
+    });
+    $kpPct = function (int $count, int $total): string {
+        if ($total <= 0) {
+            return '0.0%';
+        }
+        return number_format(($count * 100.0) / $total, 1) . '%';
+    };
+    $changeText = function ($change): string {
+        if ($change === null || ! is_numeric($change)) {
+            return '';
+        }
+        $delta = (float) $change;
+        $points = number_format(abs($delta) * 100, 1);
+        if ($delta > 0.0005) {
+            return '较上次提升' . $points . '个百分点';
+        }
+        if ($delta < -0.0005) {
+            return '较上次下降' . $points . '个百分点';
+        }
+        return '较上次基本持平';
+    };
+
+@endphp
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>学情分析报告</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: "{{ $student['name'] ?? '-' }}"; font-size: 13px; color: #666; }
+            @top-right {
+                content: "{{ $reportCode }}";
+                font-size: 19px;
+                font-weight: 600;
+                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
+                color: #222;
+            }
+            @bottom-left { content: "{{ $reportCode }}"; font-size: 11px; color: #666; }
+            @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
+        }
+        * { box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.65; }
+        .page { page-break-after: auto; }
+        .header { text-align: left; margin-bottom: 16px; }
+        .paper-title { font-size: 30px; font-weight: 700; margin-bottom: 8px; color: #0b3a75; letter-spacing: 1px; }
+        .section { margin-bottom: 14px; page-break-inside: auto; break-inside: auto; }
+        .section-title { font-size: 20px; margin-bottom: 10px; font-weight: 700; color: #0b3a75; border-left: 5px solid #3b82f6; padding-left: 10px; line-height: 1.3; }
+        .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; position: relative; }
+        .summary-list { margin: 0; padding-left: 18px; }
+        .summary-list li { margin: 6px 0; font-size: 13px; }
+        .overall-badge {
+            position: absolute;
+            right: 14px;
+            top: 12px;
+            border-radius: 12px;
+            border: 0;
+            padding: 9px 16px;
+            min-width: 0;
+            width: auto;
+            text-align: center;
+            position: absolute;
+            overflow: hidden;
+            display: inline-block;
+            white-space: nowrap;
+            background: transparent !important;
+        }
+        .overall-badge .level { font-size: 28px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; }
+        .overall-badge .score { font-size: 13px; margin-top: 3px; }
+        .overall-badge.badge-s {
+            border: 5px solid #6d28d9;
+            border-radius: 14px;
+            box-shadow: none;
+            transform: rotate(-7deg);
+        }
+        .overall-badge.badge-s::before {
+            content: "";
+            position: absolute;
+            inset: 4px;
+            border: 2px dashed rgba(109, 40, 217, 0.65);
+            border-radius: 10px;
+            pointer-events: none;
+        }
+        .overall-badge.badge-s .level {
+            letter-spacing: 2px;
+            text-shadow: 0 1px 0 rgba(109, 40, 217, 0.24);
+        }
+        .overall-badge.badge-excellent {
+            border: 3px double #16a34a;
+            border-radius: 999px;
+            box-shadow: none;
+        }
+        .overall-badge.badge-good {
+            border: 2px solid #2563eb;
+            border-radius: 10px;
+            clip-path: polygon(6% 0, 94% 0, 100% 50%, 94% 100%, 6% 100%, 0 50%);
+            box-shadow: none;
+        }
+        .overall-badge.badge-average {
+            border: 2px dashed #d97706;
+            border-radius: 14px;
+            box-shadow: none;
+        }
+        .overall-badge.badge-weak {
+            border-left: 3px solid #ef4444;
+            border-right: 0;
+            border-top: 0;
+            border-bottom: 2px solid #ef4444;
+            border-radius: 0 10px 10px 0;
+            box-shadow: none;
+        }
+        .overall-meta { margin-top: 8px; font-size: 9px; color: #64748b; line-height: 1.6; white-space: nowrap; }
+        .dot {
+            display: inline-block;
+            width: 10px;
+            height: 10px;
+            border-radius: 2px;
+            margin-right: 4px;
+            vertical-align: middle;
+            border: 1px solid #374151;
+            background: #fff;
+        }
+        .dot-mastered {
+            background: #111827;
+            border-style: solid;
+        }
+        .dot-weak {
+            background: #9ca3af;
+            border-style: solid;
+        }
+        .dot-beginner {
+            background: #e5e7eb;
+            border: 1px dashed #6b7280;
+        }
+        .dot-unlearned {
+            background: #ffffff;
+            border-style: solid;
+            border-color: #9ca3af;
+        }
+        .cluster-toolbar {
+            margin-bottom: 8px;
+            font-size: 11px;
+            color: #475569;
+            white-space: nowrap;
+        }
+        .cluster-legend { display: inline-block; margin-right: 12px; }
+        .cluster-grid {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 10px;
+        }
+        .cluster-card {
+            border: 1px solid #e2e8f0;
+            border-radius: 10px;
+            padding: 10px;
+            background: #fff;
+            position: relative;
+            overflow: visible;
+        }
+        .cluster-card-title {
+            font-size: 14px;
+            font-weight: 700;
+            color: #0f172a;
+            margin-bottom: 8px;
+        }
+        .cluster-subgroup {
+            border-left: 2px solid #e5e7eb;
+            padding-left: 8px;
+            padding-right: 128px; /* 右侧空白区域再缩小 */
+            margin-bottom: 8px;
+            position: relative;
+        }
+        .cluster-subgroup:last-child { margin-bottom: 0; }
+        .cluster-subgroup-title {
+            font-size: 12px;
+            font-weight: 600;
+            color: #334155;
+            margin-bottom: 4px;
+        }
+        .cluster-points {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 4px;
+        }
+        .cluster-point {
+            width: 10px;
+            height: 10px;
+            border-radius: 2px;
+            display: inline-block;
+            border: 1px solid rgba(148, 163, 184, 0.35);
+            position: relative;
+        }
+        .cluster-point.status-mastered {
+            background: #111827;
+            border: 1px solid #1f2937;
+        }
+        .cluster-point.status-weak {
+            background: #9ca3af;
+            border: 1px solid #1f2937;
+        }
+        .cluster-point.status-beginner {
+            background: #e5e7eb !important;
+            border: 1px dashed #6b7280;
+        }
+        .cluster-point.status-unlearned {
+            background: #ffffff !important;
+            border: 1px solid #9ca3af;
+        }
+        .cluster-point.focus-source {
+            border-color: rgba(148, 163, 184, 0.35);
+            box-shadow: 0 0 0 2px #fde68a, 0 0 0 4px rgba(251, 191, 36, 0.18);
+            margin-right: 4px;
+            margin-bottom: 4px;
+            z-index: 2;
+            overflow: visible;
+        }
+        .cluster-focus-connector {
+            position: absolute;
+            left: 0;
+            top: -12px;
+            width: 112px;
+            height: 46px;
+            overflow: visible;
+            pointer-events: none;
+            z-index: 2;
+        }
+        .cluster-focus-connector path {
+            fill: none;
+            stroke: #0f172a;
+            stroke-width: 1;
+            stroke-linecap: round;
+        }
+        .cluster-focus-connector.dense {
+            width: 128px;
+            height: 46px;
+        }
+        .cluster-focus-connector.bottom {
+            width: 118px;
+            height: 46px;
+        }
+        .cluster-point-focus-label {
+            position: absolute;
+            left: 102px; /* 放到右侧空白区,并远离最右侧方块 */
+            top: 50%;   /* 与点位在同一水平带,避免压住文字 */
+            transform: translateY(-50%);
+            display: inline-block;
+            max-width: none;
+            border: 1px solid #0f172a;
+            border-radius: 6px;
+            background: #fffbeb;
+            color: #92400e;
+            font-size: 9px;
+            font-weight: 700;
+            padding: 1px 6px;
+            line-height: 1.25;
+            white-space: nowrap;
+            z-index: 3;
+            overflow: visible;
+        }
+        .cluster-point-focus-label.focus-offset-a { top: 50%; left: 102px; transform: translateY(-50%); }
+        .cluster-point-focus-label.focus-offset-b { top: 42%; left: 102px; transform: translateY(-50%); }
+        .cluster-point-focus-label.focus-offset-c { top: 58%; left: 102px; transform: translateY(-50%); }
+        .cluster-point-focus-label.dense { left: 116px; top: 50%; transform: translateY(-50%); }
+        .cluster-point-focus-label.bottom { left: 108px; top: 44%; transform: translateY(-50%); }
+        .cluster-empty {
+            font-size: 12px;
+            color: #64748b;
+            background: #f8fafc;
+            border: 1px dashed #cbd5e1;
+            border-radius: 8px;
+            padding: 10px;
+        }
+        .kp-stats-grid {
+            display: grid;
+            grid-template-columns: repeat(5, 1fr);
+            border: 1px solid #e5e7eb;
+            border-radius: 10px;
+            overflow: hidden;
+            margin-bottom: 10px;
+        }
+        .kp-stat-item {
+            padding: 8px 10px;
+            border-right: 1px solid #e5e7eb;
+            background: #fff;
+        }
+        .kp-stat-item:last-child { border-right: none; }
+        .kp-stat-label { font-size: 11px; color: #64748b; }
+        .kp-stat-value { font-size: 18px; font-weight: 700; color: #111827; line-height: 1.2; margin-top: 2px; }
+        .kp-stat-rate { font-size: 11px; margin-left: 4px; font-weight: 600; }
+        .kp-change-box { margin-bottom: 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; padding: 10px 12px; }
+        .kp-change-list { margin: 4px 0 0 16px; padding: 0; }
+        .kp-change-list li { margin: 2px 0; color: #334155; }
+        .kp-burst-card { margin-top: 10px; border: 1px solid #dbeafe; border-radius: 12px; padding: 10px; background: #fff; }
+        .kp-burst-title { font-size: 13px; font-weight: 700; margin-bottom: 6px; color: #0b3a75; }
+        .kp-burst-meta { font-size: 12px; color: #334155; margin-top: 6px; line-height: 1.6; }
+        .kp-burst-list { margin-top: 6px; font-size: 11px; color: #334155; line-height: 1.5; }
+        .kp-burst-list span { display: inline-block; margin-right: 10px; margin-bottom: 3px; }
+        table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
+        th, td { border: 1px solid #d0d7e2; padding: 8px 10px; text-align: left; vertical-align: top; }
+        th { background: #f1f5f9; color: #1e293b; font-weight: 700; }
+        .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 600; }
+        .module-table th { background: #edf2ff; color: #0f172a; }
+        .module-table th { text-align: center; }
+        .module-table td { line-height: 1.45; }
+        .module-table th,
+        .module-table td { vertical-align: middle; }
+        .module-table th:nth-child(6) { vertical-align: middle; }
+        .module-table td:nth-child(6) { vertical-align: middle; text-align: center; }
+        .module-table th:nth-child(1),
+        .module-table td:nth-child(1) { text-align: center; }
+        .module-table td:nth-child(2),
+        .module-table td:nth-child(3),
+        .module-table td:nth-child(4),
+        .module-table td:nth-child(5) { text-align: center; white-space: nowrap; }
+        .module-table td:nth-child(6) { font-size: 11px; color: #334155; }
+        .module-table tbody tr:nth-child(even) td { background: #fcfdff; }
+        .module-name { font-weight: 600; color: #0f172a; }
+        .impact-yes { color:#2563eb; font-weight:600; }
+        .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #334155; background: #e5e7eb; }
+        .error-kp-tag { display: inline-block; margin: 0 6px 6px 0; padding: 1px 7px; border-radius: 999px; font-size: 10px; color: #334155; background: #f8fafc; border: 1px solid #d1d5db; }
+        .error-kp-tag.high-risk { color: #b91c1c; border-color: #fca5a5; background: #fff; font-weight: 600; }
+        .muted { color: #6b7280; font-size: 12px; }
+    </style>
+</head>
+<body>
+<div class="page">
+    <div class="header">
+        <h1 class="paper-title">学情分析报告</h1>
+    </div>
+
+    <div class="section">
+        <div class="section-title">一、总体评估</div>
+        <div class="card">
+            <div class="overall-badge {{ $overallVisual['class'] ?? '' }}"
+                 style="border-color:{{ $overallVisual['border'] }}; color:{{ $overallVisual['text'] }};">
+                <div class="level">{{ $overallGrade }}</div>
+            </div>
+            <ul class="summary-list">
+                <li>本次诊断得分:
+                    @if($scoreObtained !== null && $scoreTotal !== null && $scoreTotal > 0)
+                        {{ rtrim(rtrim(number_format((float) $scoreObtained, 1), '0'), '.') }}/{{ rtrim(rtrim(number_format((float) $scoreTotal, 1), '0'), '.') }}
+                    @else
+                        暂无得分数据
+                    @endif
+                </li>
+                <li>平均掌握度:{{ $averageMastery !== null ? number_format((float) $averageMastery * 100, 1) . '%' : '暂无掌握度' }}</li>
+                <li>
+                    难度匹配:
+                    @if(!empty($difficultySummary['target_label']) && isset($difficultySummary['actual_average_difficulty']))
+                        目标 {{ $difficultySummary['target_label'] }}
+                        @if(!empty($difficultySummary['target_range']))
+                            ({{ number_format((float)($difficultySummary['target_range']['min'] ?? 0), 2) }}~{{ number_format((float)($difficultySummary['target_range']['max'] ?? 0), 2) }})
+                        @endif
+                        ,实际 {{ number_format((float)($difficultySummary['actual_average_difficulty'] ?? 0), 3) }}
+                        ({{ $difficultySummary['status'] ?? '暂无' }})
+                    @else
+                        暂无难度匹配数据
+                    @endif
+                </li>
+                @if(!empty($difficultySummary['explain']))
+                    <li>难度说明:{{ $difficultySummary['explain'] }}</li>
+                @endif
+                <li>
+                    与历史自己对比:
+                    @if(!empty($historySummary['is_first_exam']))
+                        {{ $historySummary['message'] ?? '这是你的第一次分析报告,先积累样本再看趋势。' }}
+                    @elseif(!empty($historySummary['low_baseline_guard']))
+                        {{ $historySummary['message'] ?? '历史基线偏低,建议看连续趋势。' }}
+                    @elseif(!empty($historySummary['has_data']))
+                        @php
+                            $trendText = (string)($historySummary['trend'] ?? '—');
+                            $tVisual = $trendVisual($trendText);
+                        @endphp
+                        近几次均值对比:
+                        {{ number_format((float)($historySummary['baseline_score_rate'] ?? 0) * 100, 1) }}%,
+                        本次{{ ($historySummary['delta_score_rate'] ?? 0) >= 0 ? '提升' : '回落' }}
+                        {{ number_format(abs((float)($historySummary['delta_score_rate'] ?? 0)) * 100, 1) }}%
+                        (<span style="color:{{ $tVisual['color'] ?? '#64748b' }}; font-weight:600;">{{ $tVisual['icon'] ?? '•' }} {{ $trendText }}</span>)
+                    @else
+                        {{ $historySummary['message'] ?? '历史样本不足' }}
+                    @endif
+                </li>
+                @if(!empty($peerSummary['show_line']))
+                    <li>
+                        与同群体对比:
+                        {{ $peerSummary['message'] ?? '' }}
+                        (<span style="color:{{ $peerSummary['band_color'] ?? '#64748b' }}; font-weight:600;">{{ $peerSummary['band_icon'] ?? '•' }} {{ $peerSummary['band'] ?? '—' }}</span>)
+                    </li>
+                @endif
+                <li>
+                    整体水平:
+                    @if($overallScore !== null)
+                        {{ number_format($overallScore, 1) }} 分({{ $overallGrade }})
+                    @else
+                        待计算
+                    @endif
+                </li>
+            </ul>
+            <div class="overall-meta">
+                规则:综合分 = 当前50% + 历史25% + 同群体25% + 难度校正,即:(({{ number_format($scoreRate !== null ? (float)$scoreRate * 100 : 0, 1) }}×70% + {{ number_format($averageMastery !== null ? (float)$averageMastery * 100 : 0, 1) }}×30%)×50%) + {{ number_format($historyPart, 1) }}×25% + {{ number_format($peerPart, 1) }}×25% + {{ number_format($adjustPart, 1) }} = {{ number_format($overallScore ?? $compositeFormulaResult, 1) }}
+            </div>
+        </div>
+    </div>
+
+    <div class="section">
+        <div class="section-title">二、知识点掌握聚类视图</div>
+        <div class="cluster-toolbar">
+            <span class="cluster-legend"><i class="dot dot-mastered"></i>已掌握(深色实心)</span>
+            <span class="cluster-legend"><i class="dot dot-weak"></i>薄弱(浅灰实心)</span>
+            <span class="cluster-legend"><i class="dot dot-beginner"></i>未入门(浅灰虚线框)</span>
+            <span class="cluster-legend"><i class="dot dot-unlearned"></i>未学习(白色)</span>
+            <span>按“模块 → 子模块 → 知识点”聚类展示</span>
+        </div>
+        <div class="cluster-grid">
+	            @foreach($clusterCards as $cluster)
+	                <div class="cluster-card">
+                        @php
+                            $clusterModuleName = trim((string) ($cluster['module_name'] ?? '未分组'));
+                            $clusterGrandName = trim((string) ($cluster['grand_name'] ?? ''));
+                            $clusterTitle = ($clusterGrandName !== '' && $clusterGrandName !== $clusterModuleName)
+                                ? ($clusterModuleName . ' / ' . $clusterGrandName)
+                                : $clusterModuleName;
+                        @endphp
+	                    <div class="cluster-card-title">
+	                        {{ $clusterTitle }}
+	                    </div>
+                    @if(!empty($cluster['parent_groups']))
+                        @foreach($cluster['parent_groups'] as $parent)
+                            <div class="cluster-subgroup">
+                                <div class="cluster-subgroup-title">{{ $parent['parent_name'] }}</div>
+                                <div class="cluster-points">
+	                                    @foreach($parent['points'] as $point)
+                                            @php
+                                                $pointCode = trim((string) ($point['code'] ?? ''));
+                                                $pointParentCode = trim((string) ($point['parent_code'] ?? ''));
+                                                $focusMarker = null;
+                                                $focusMarkerCode = '';
+                                                if ($pointCode !== '' && isset($focusMarkerByCode[$pointCode]) && empty($renderedFocusMarkerCodes[$pointCode])) {
+                                                    $focusMarker = $focusMarkerByCode[$pointCode];
+                                                    $focusMarkerCode = $pointCode;
+                                                } elseif ($pointParentCode !== '' && isset($focusMarkerByCode[$pointParentCode]) && empty($renderedFocusMarkerCodes[$pointParentCode])) {
+                                                    $focusMarker = $focusMarkerByCode[$pointParentCode];
+                                                    $focusMarkerCode = $pointParentCode;
+                                                }
+                                                if ($focusMarkerCode !== '') {
+                                                    $renderedFocusMarkerCodes[$focusMarkerCode] = true;
+                                                }
+                                                $focusName = is_array($focusMarker) ? (string) ($focusMarker['name'] ?? '') : '';
+                                                $pointStatusClass = match ((string) ($point['status'] ?? '')) {
+                                                    '已掌握' => 'status-mastered',
+                                                    '薄弱' => 'status-weak',
+                                                    '未入门' => 'status-beginner',
+                                                    default => 'status-unlearned',
+                                                };
+                                                $focusLayoutClass = '';
+                                                if ($focusName === '幂与指数') {
+                                                    $focusLayoutClass = 'dense';
+                                                } elseif (str_contains($clusterModuleName, '图形变化') || str_contains($clusterModuleName, '图形度量')) {
+                                                    $focusLayoutClass = 'bottom';
+                                                }
+                                            @endphp
+	                                        <span class="cluster-point {{ $pointStatusClass }}{{ $focusName !== '' ? ' focus-source' : '' }}"
+		                                              title="{{ $point['name'] }} · {{ $point['status'] }}{{ $point['mastery_level'] !== null ? '(' . $formatMasteryPct($point['mastery_level']) . ')' : '' }}{{ $point['path'] !== '' ? ' · ' . $point['path'] : '' }}">
+                                                @if($focusName !== '')
+                                                    @php
+                                                        $focusOffsetClass = match ($loop->index % 3) {
+                                                            1 => 'focus-offset-b',
+                                                            2 => 'focus-offset-c',
+                                                            default => 'focus-offset-a',
+                                                        };
+                                                    @endphp
+                                                    <svg class="cluster-focus-connector {{ $focusLayoutClass }}" viewBox="0 0 150 64" preserveAspectRatio="none" aria-hidden="true">
+                                                        @if($focusLayoutClass === 'dense')
+                                                            <path d="M8,24 C18,24 24,18 38,16 C84,14 124,20 138,23 L146,24" />
+                                                        @elseif($focusLayoutClass === 'bottom')
+                                                            <path d="M8,23 C18,23 24,16 42,13 C86,11 114,20 132,23 L140,23" />
+                                                        @else
+                                                            <path d="M8,24 C18,24 24,18 42,15 C86,13 114,21 132,24 L142,24" />
+                                                        @endif
+                                                    </svg>
+                                                    <span class="cluster-point-focus-label {{ $focusOffsetClass }} {{ $focusLayoutClass }}">{{ $focusName }}</span>
+                                                @endif
+                                            </span>
+	                                    @endforeach
+                                </div>
+                            </div>
+                        @endforeach
+                    @else
+                        <div class="cluster-empty">当前模块暂无可展示的子知识点。</div>
+                    @endif
+                </div>
+            @endforeach
+        </div>
+        <div style="margin-top:10px;">
+            <div class="kp-stats-grid">
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">总知识点数</div>
+                    <div class="kp-stat-value">{{ $kpStatsTotal['total'] }}</div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">已掌握</div>
+                    <div class="kp-stat-value" style="color:#52c41a;">
+                        {{ $kpStatsTotal['mastered'] }}<span class="kp-stat-rate" style="color:#52c41a;">({{ $kpPct($kpStatsTotal['mastered'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">薄弱</div>
+                    <div class="kp-stat-value" style="color:#faad14;">
+                        {{ $kpStatsTotal['weak'] }}<span class="kp-stat-rate" style="color:#faad14;">({{ $kpPct($kpStatsTotal['weak'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">未入门</div>
+                    <div class="kp-stat-value" style="color:#f5222d;">
+                        {{ $kpStatsTotal['beginner'] }}<span class="kp-stat-rate" style="color:#f5222d;">({{ $kpPct($kpStatsTotal['beginner'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+                <div class="kp-stat-item">
+                    <div class="kp-stat-label">未学习</div>
+                    <div class="kp-stat-value" style="color:#9ca3af;">
+                        {{ $kpStatsTotal['unlearned'] }}<span class="kp-stat-rate" style="color:#9ca3af;">({{ $kpPct($kpStatsTotal['unlearned'], $kpStatsTotal['total']) }})</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="section">
+        <div class="section-title">三、模块现状与提分路径(全局+本学案影响)</div>
+        <div class="kp-change-box">
+            <div style="font-size:12px;font-weight:700;color:#0f172a;">本学案知识点变化情况</div>
+            @if(!empty($kpChangeItems))
+                <ul class="kp-change-list">
+	                    @foreach($kpChangeItems as $item)
+	                        @php
+	                            $delta = (float) ($item['change'] ?? 0);
+	                            $deltaColor = $delta > 0 ? '#16a34a' : ($delta < 0 ? '#dc2626' : '#64748b');
+	                            $deltaText = $changeText($delta);
+		                            $masteryText = isset($item['mastery_level']) && $item['mastery_level'] !== null
+		                                ? $formatMasteryPct($item['mastery_level'])
+		                                : '--';
+	                        @endphp
+	                        <li>
+	                            {{ $item['name'] ?? '-' }}:
+	                            当前掌握度{{ $masteryText }}({{ $item['status'] ?? '未学习' }})
+	                            @if($deltaText !== '')
+	                                ,<span style="color:{{ $deltaColor }};font-weight:600;">{{ $deltaText }}</span>
+	                            @endif
+	                        </li>
+                    @endforeach
+                </ul>
+            @else
+                <div class="muted" style="margin-top:4px;">
+                    @if(!empty($kpWrongStats))
+                        暂无本学案命中知识点的掌握度数据,以下方知识点错误率作为本学案影响依据。
+                    @else
+                        暂无可用的知识点变化数据
+                    @endif
+                </div>
+            @endif
+        </div>
+        @if(!empty($kpWrongStats))
+            <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff7ed;">
+                <div style="font-size:12px; font-weight:700; color:#9a3412; margin-bottom:6px;">知识点错误率</div>
+                <div style="font-size:12px; color:#475569; line-height:1.7;">
+                    @foreach($kpWrongStats as $item)
+                        <span class="error-kp-tag {{ $item['rate'] > 0.5 ? 'high-risk' : '' }}">{{ $item['kp_name'] }}:{{ $item['wrong'] }}/{{ $item['total'] }}({{ number_format($item['rate'] * 100, 1) }}%)</span>
+                    @endforeach
+                </div>
+            </div>
+        @endif
+        <div style="margin-bottom:8px; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff;">
+            <div style="font-size:12px;color:#334155;">
+                本次学案影响模块:
+	                @if(!empty($impactedModules))
+	                    @foreach($impactedModules as $idx => $im)
+	                        @php
+                                $mName = $im['module_name'] ?? '-';
+                                $mQuestionCount = (int) ($im['question_count'] ?? 0);
+                                $mScore = $im['exam_obtained_score'] ?? null;
+                            @endphp
+	                        <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#3730a3;margin-right:4px;">
+	                            {{ $mName }}({{ $mQuestionCount }}题,得分{{ $mScore !== null ? number_format((float) $mScore, 1) : '-' }})
+	                        </span>
+	                    @endforeach
+                @else
+                    <span class="muted">暂无命中模块</span>
+                @endif
+            </div>
+        </div>
+        <table class="module-table">
+            <thead>
+            <tr>
+	                <th style="width: 18%;">模块</th>
+	                <th style="width: 12%; white-space: nowrap;">本次影响</th>
+	                <th style="width: 18%;">当前掌握度</th>
+	                <th style="width: 14%;">掌握状态</th>
+	                <th style="width: 14%;">路径建议</th>
+	                <th style="width: 24%;">关注知识点</th>
+            </tr>
+            </thead>
+            <tbody>
+            @forelse($moduleRowsWithStatus as $m)
+                @php
+	                    $status = (string) ($m['status'] ?? '暂无');
+	                    $color = $statusColor($status);
+	                    $qCount = (int) ($m['question_count'] ?? 0);
+                    $isImpacted = $qCount > 0;
+                    $basePathTag = $pathTagByModuleName[(string) ($m['module_name'] ?? '')]
+                        ?? $globalPathTagByMastery($m['mastery_level'] ?? null);
+                    $pathTag = $isImpacted ? $basePathTag : $overallPathTag($basePathTag);
+                    $pathColor = match ($pathTag) {
+                        '优先加强', '整体优先' => '#ef4444',
+                        '需要加强', '整体加强' => '#f59e0b',
+                        '保分不错', '整体巩固' => '#16a34a',
+                        default => '#64748b',
+                    };
+	                    $moduleName = (string) ($m['module_name'] ?? '');
+                    $focus = $moduleSuggestionByName[$moduleName] ?? null;
+                    $focusText = '-';
+                    if (is_array($focus)) {
+                        if (!empty($focus['is_empty'])) {
+                            $focusText = (string) ($focus['status'] ?? '当前模块暂无需额外关注知识点');
+                        } else {
+                            $focusName = (string) ($focus['kp_name'] ?? '');
+	                            $focusMastery = isset($focus['mastery_level']) && $focus['mastery_level'] !== null
+	                                ? $formatMasteryPct($focus['mastery_level'])
+	                                : '--';
+                            $focusText = $focusName !== ''
+                                ? ($focusName . '(' . $focusMastery . ')')
+                                : '当前模块暂无需额外关注知识点';
+                        }
+                    }
+                @endphp
+                <tr>
+                    <td><span class="module-name">{{ $m['module_name'] ?? '-' }}</span></td>
+                    <td>
+                        @if($isImpacted)
+                            <span class="impact-yes">是</span>
+                        @else
+                            <span class="muted">否</span>
+                        @endif
+                    </td>
+		                    <td>{{ isset($m['mastery_level']) && $m['mastery_level'] !== null ? $formatMasteryPct($m['mastery_level']) : '-' }}</td>
+	                    <td><span class="badge" style="background:{{ $color }}">{{ $status }}</span></td>
+	                    <td><span style="color:{{ $pathColor }}; font-weight:700;">{{ $pathTag }}</span></td>
+	                    <td>{{ $focusText }}</td>
+                </tr>
+            @empty
+                <tr>
+	                    <td colspan="6" class="muted">暂无掌握状态数据</td>
+                </tr>
+            @endforelse
+            </tbody>
+        </table>
+    </div>
+</div>
+<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>

+ 5 - 1
resources/views/exam-analysis/pdf-report.blade.php

@@ -2,7 +2,11 @@
     // 提取15位paper_id数字部分作为学案编号
     $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
     preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
-    $reportCode = $matches[1] ?? preg_replace('/[^0-9]/', '', $rawPaperId);
+    $reportCode = $matches[1] ?? '';
+    if ($reportCode === '') {
+        $digits = preg_replace('/[^0-9]/', '', $rawPaperId);
+        $reportCode = substr(str_pad($digits, 15, '0', STR_PAD_LEFT), -15);
+    }
     $averageMastery = isset($mastery['average']) ? number_format($mastery['average'] * 100, 1) . '%' : '无数据';
 
     // 【修复】从insights中获取AI分析结果(而不是从analysis_data)

+ 10 - 0
resources/views/filament/pages/question-detail.blade.php

@@ -270,6 +270,16 @@
         height: auto;
         display: block;
     }
+    .question-stem img,
+    .solution-content img {
+        max-width: 100%;
+        max-height: 240px;
+        height: auto;
+        object-fit: contain;
+        display: block;
+        margin-left: auto;
+        margin-right: auto;
+    }
     .solution-content {
         white-space: pre-line;
     }

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

@@ -165,6 +165,10 @@
         .option-long { white-space: normal; word-break: break-word; }
         .option-compact { line-height: inherit; }
         .option p, .option div { margin: 0; display: inline; }
+        .parallel {
+            font-family: "NotoSansMonoCJKjp", monospace;
+            letter-spacing: 1px;
+        }
         .option .katex {
             font-size: 1em !important;
             vertical-align: 0;
@@ -333,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,

+ 3 - 3
resources/views/pdf/exam-knowledge-explanation.blade.php

@@ -1,16 +1,16 @@
-{{-- 知识点讲解模板 --}}
+{{-- 知识点梳理模板 --}}
 <!DOCTYPE html>
 <html lang="zh-CN">
 <head>
     <meta charset="UTF-8">
-    <title>知识点讲解</title>
+    <title>知识点梳理</title>
     <link rel="stylesheet" href="/css/katex/katex.min.css">
     @include('pdf.partials.kp-explain-styles')
 </head>
 <body>
     <div class="page">
         <div class="kp-explain-header">
-            <div class="kp-explain-title">知识点讲解</div>
+            <div class="kp-explain-title">知识点梳理</div>
             <div class="kp-explain-subtitle">本章节用于梳理本卷涉及的知识点,帮助学生在做题前完成预习/复盘。</div>
         </div>
 

+ 6 - 138
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; }
@@ -165,53 +129,12 @@
             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;
+        .parallel {
+            font-family: "NotoSansMonoCJKjp", monospace;
+            letter-spacing: 1px;
         }
-        .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; }
+        /* 选项网格与图片尺寸统一复用 paper-body-core-styles,避免此处覆盖造成4列溢出 */
         .option-compact { line-height: inherit; }
-        .option p, .option div { margin: 0; display: inline; }
         .option .katex {
             font-size: 1em !important;
             vertical-align: 0;
@@ -364,62 +287,7 @@
         .wavy-underline.short {
             min-width: 60px;
         }
-        /* PDF图片容器:防止图片跨页分割 - 增强版 */
-        .pdf-figure {
-            break-inside: avoid;
-            page-break-inside: avoid;
-            -webkit-column-break-inside: avoid;
-            break-before: avoid;
-            break-after: avoid;
-            page-break-before: avoid;
-            page-break-after: avoid;
-            margin: 8px 0;
-            display: block;
-            /* 确保图片不会在页面底部被截断 */
-            min-height: 30px;
-            /* 限制独立图块尺寸,避免图片压过题干 */
-            max-height: 82mm;
-        }
-        .pdf-figure img {
-            max-width: min(84%, 420px);
-            max-height: 82mm;
-            width: auto;
-            height: auto;
-            display: block;
-            margin: 0 auto;
-            object-fit: contain;
-            box-sizing: border-box;
-            -webkit-print-color-adjust: exact;
-            print-color-adjust: exact;
-            image-rendering: -webkit-optimize-contrast;
-        }
-        /* 题干中的图片样式(向后兼容) */
-        .question-stem img,
-        .question-main img,
-        .question-content img,
-        .answer-meta img,
-        .answer-line img,
-        .solution-content img,
-        .solution-section img,
-        .solution-parsed img {
-            display: block;
-            max-width: 220px;
-            max-height: 60mm;
-            width: auto;
-            height: auto;
-            margin: 6px auto;
-            box-sizing: border-box;
-            object-fit: contain;
-            -webkit-print-color-adjust: exact;
-            print-color-adjust: exact;
-            image-rendering: -webkit-optimize-contrast;
-        }
-        /* 选项中的图片样式 - 防止超出容器 */
-        .option img {
-            max-width: 100%;
-            height: auto;
-            vertical-align: middle;
-        }
+        @include('pdf.partials.paper-exam-shared-image-styles')
         @media print {
             .no-print {
                 display: none;

+ 10 - 9
resources/views/pdf/partials/answer-detail-page.blade.php

@@ -11,20 +11,25 @@
         ->sortBy(fn ($item) => (int) (($item['q']->question_number ?? 0)))
         ->values();
 
-    $normalizeQuickAnswer = function (?string $answer): string {
+    $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([';', ';'], ' / ', $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 ($normalizeQuickAnswer) {
+    $quickAnswers = $allQuestions->map(function ($item) use ($normalizeAnswerText) {
         $q = $item['q'];
-        $answerText = $normalizeQuickAnswer($q->answer ?? '');
+        $answerText = $normalizeAnswerText($q->answer ?? '', true);
         $isLong = mb_strlen(strip_tags($answerText)) > 20
             || str_contains($answerText, "\n")
             || str_contains($answerText, "\r")
@@ -32,8 +37,6 @@
             || str_contains($answerText, '\\frac')
             || str_contains($answerText, '见解析');
         $processedAnswer = \App\Services\MathFormulaProcessor::processFormulas($answerText);
-        // 速查统一为流式:移除显式换行
-        $processedAnswer = preg_replace('/<br\s*\/?>/iu', ' ', $processedAnswer) ?? $processedAnswer;
 
         return [
             'no' => (int) ($q->question_number ?? 0),
@@ -195,9 +198,7 @@
                 $hasImageLikeSolution = (bool) preg_match('/<\s*img\b|<\s*image\b|(^|[\s>])img\s+src\s*=/iu', $rawSolution);
                 $showAnswer = !$isSeeSolutionAnswer;
 
-                $renderAnswer = $rawAnswer !== ''
-                    ? \App\Services\MathFormulaProcessor::processFormulas($rawAnswer)
-                    : '—';
+                $renderAnswer = \App\Services\MathFormulaProcessor::processFormulas($normalizeAnswerText($rawAnswer, false));
                 if ($rawSolution === '') {
                     $renderSolution = '(无详解)';
                 } elseif ($hasImageLikeSolution) {

+ 5 - 0
resources/views/pdf/partials/answer-detail-styles.blade.php

@@ -4,6 +4,11 @@
     padding: 0 12px;
 }
 
+.parallel {
+            font-family: "NotoSansMonoCJKjp", monospace;
+            letter-spacing: 1px;
+        }
+
 .answer-quick {
     border: 1px solid #d8d8d8;
     border-radius: 4px;

+ 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) {

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

@@ -0,0 +1,161 @@
+/* 大题标题:不与后面内容分开 */
+.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;
+}
+/* 题干插图尺寸见 pdf/partials/paper-exam-shared-image-styles(与 exam-paper / question-check 共用) */
+.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, minmax(0, 1fr));
+    gap: 8px 12px;
+    page-break-inside: avoid;
+    break-inside: avoid;
+}
+.options-grid-2 {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px 12px;
+    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;
+}
+.options-grid-4 .option {
+    min-width: 0;
+}
+.option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+.option-value {
+    display: inline-block;
+    max-width: calc(100% - 24px);
+    min-width: 0;
+}
+.options-grid-4 .option-value {
+    display: block;
+    flex: 1 1 0;
+    min-width: 0;
+    max-width: calc(100% - 24px);
+}
+.options-grid-4 .option img {
+    width: 100% !important;
+    max-width: 100% !important;
+    max-height: 28mm !important;
+    height: auto !important;
+}
+.options-grid-4 .option svg {
+    width: 100% !important;
+    max-width: 100% !important;
+    max-height: 28mm !important;
+    height: auto !important;
+    display: block;
+}
+.option-short { white-space: nowrap; }
+.option-long { white-space: normal; word-break: break-word; }
+.options-grid-4 .option-short { white-space: normal; }
+.option.option-with-image {
+    align-items: flex-start;
+}
+.option.option-with-image .option-value {
+    display: block;
+}
+.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; }

+ 68 - 0
resources/views/pdf/partials/paper-exam-shared-image-styles.blade.php

@@ -0,0 +1,68 @@
+{{-- 与 pdf/exam-paper 一致:题干/选项插图上限(质检 PDF 原先缺少此段导致图过大) --}}
+/* PDF图片容器:防止图片跨页分割 - 增强版 */
+.pdf-figure {
+    break-inside: avoid;
+    page-break-inside: avoid;
+    -webkit-column-break-inside: avoid;
+    break-before: avoid;
+    break-after: avoid;
+    page-break-before: avoid;
+    page-break-after: avoid;
+    margin: 8px 0;
+    display: block;
+    min-height: 30px;
+    max-height: 82mm;
+}
+.pdf-figure img {
+    max-width: min(84%, 420px);
+    max-height: 82mm;
+    width: auto;
+    height: auto;
+    display: block;
+    margin: 0 auto;
+    object-fit: contain;
+    box-sizing: border-box;
+    -webkit-print-color-adjust: exact;
+    print-color-adjust: exact;
+    image-rendering: -webkit-optimize-contrast;
+}
+/* 题干中的图片样式(与常规组卷学生卷 PDF 一致) */
+.question-stem img,
+.question-main img,
+.question-content img,
+.answer-meta img,
+.answer-line img,
+.solution-content img,
+.solution-section img,
+.solution-parsed img {
+    display: block;
+    max-width: 220px;
+    max-height: 60mm;
+    width: auto;
+    height: auto;
+    margin: 6px auto;
+    box-sizing: border-box;
+    object-fit: contain;
+    -webkit-print-color-adjust: exact;
+    print-color-adjust: exact;
+    image-rendering: -webkit-optimize-contrast;
+}
+.option img {
+    display: block;
+    max-width: 100%;
+    max-height: 42mm;
+    width: auto;
+    height: auto;
+    object-fit: contain;
+    margin: 2px 0;
+    vertical-align: top;
+}
+.option svg {
+    display: block;
+    max-width: 100%;
+    max-height: 42mm;
+    width: 100%;
+    height: auto;
+    margin: 2px 0;
+    vertical-align: top;
+}

+ 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>

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

@@ -0,0 +1,91 @@
+@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')
+        @include('pdf.partials.paper-exam-shared-image-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>

+ 88 - 0
scripts/audit_question_stem_quality.php

@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
+$kernel->bootstrap();
+
+use Illuminate\Support\Facades\DB;
+
+$limit = isset($argv[1]) ? max(1, (int) $argv[1]) : 50000;
+$outDir = isset($argv[2]) ? rtrim($argv[2], '/'): '/tmp';
+$table = isset($argv[3]) ? trim((string) $argv[3]) : 'questions';
+@mkdir($outDir, 0777, true);
+
+$rows = DB::connection('remote_mysql')
+    ->table($table)
+    ->select('id', 'question_type', 'stem')
+    ->whereNotNull('stem')
+    ->orderByDesc('id')
+    ->limit($limit)
+    ->get();
+
+$issues = [
+    'unbalanced_dollar' => [],
+    'suspicious_latex_env' => [],
+    'mixed_placeholder_inside_math' => [],
+    'compare_blank_between_math_tokens' => [],
+    'tail_backslash_dollar_marker' => [],
+];
+
+$push = static function(array &$bucket, object $row, string $reason): void {
+    if (count($bucket) >= 2000) {
+        return;
+    }
+    $bucket[] = [
+        'id' => (int) $row->id,
+        'question_type' => (string) $row->question_type,
+        'reason' => $reason,
+        'stem_preview' => mb_substr((string) $row->stem, 0, 220),
+    ];
+};
+
+foreach ($rows as $row) {
+    $stem = (string) $row->stem;
+
+    $dollarCount = substr_count($stem, '$');
+    if (($dollarCount % 2) !== 0) {
+        $push($issues['unbalanced_dollar'], $row, 'odd number of $ delimiters');
+    }
+
+    if (preg_match('/\\\\begin\{[^}]*$/u', $stem) || preg_match('/\\\\end\{[^}]*$/u', $stem)) {
+        $push($issues['suspicious_latex_env'], $row, 'truncated \\begin/\\end block');
+    }
+
+    if (preg_match('/\$(?:[^$]|\\\\.)*(?:\\\\underline\{[^}]*\}|_{2,}|[((](?:\s|&nbsp;|&#160;| )*[))])(?:[^$]|\\\\.)*\$/u', $stem)) {
+        $push($issues['mixed_placeholder_inside_math'], $row, 'placeholder token appears inside $...$');
+    }
+
+    if (preg_match('/\$[^$]*\$\s*_{2,}\s*\$[^$]*\$/u', $stem) || preg_match('/\$[^$]*\$\s*[((](?:\s|&nbsp;|&#160;| )*[))]\s*\$[^$]*\$/u', $stem)) {
+        $push($issues['compare_blank_between_math_tokens'], $row, 'blank token inserted between two math segments');
+    }
+
+    if (preg_match('/\\\\+\$(?=\s*(?:<[^>]+>\s*)*$)/u', $stem)) {
+        $push($issues['tail_backslash_dollar_marker'], $row, 'tail backslash-dollar marker used as blank placeholder');
+    }
+}
+
+$summary = [
+    'table' => $table,
+    'scan_limit' => $limit,
+    'scanned_rows' => count($rows),
+    'counts' => array_map('count', $issues),
+    'generated_at' => date('c'),
+];
+
+$stamp = date('Ymd_His');
+$summaryPath = "$outDir/question_stem_quality_summary_$stamp.json";
+$detailPath = "$outDir/question_stem_quality_details_$stamp.json";
+file_put_contents($summaryPath, json_encode($summary, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT));
+file_put_contents($detailPath, json_encode($issues, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT));
+
+echo json_encode([
+    'summary_path' => $summaryPath,
+    'detail_path' => $detailPath,
+    'summary' => $summary,
+], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT), "\n";

+ 220 - 0
scripts/audit_rendered_placeholder_integrity.php

@@ -0,0 +1,220 @@
+<?php
+
+/**
+ * 全库题干「下划线占位 + 句点小黑点」流水线校验(与 paper-body 选择/填空口径对齐)。
+ *
+ * 用法:
+ *   php scripts/audit_rendered_placeholder_integrity.php [--connection mysql] [--table questions]
+ *       [--chunk 2000] [--out-dir storage/app/audit_placeholder]
+ *       [--types choice,fill]
+ *       [--check-unbalanced-dollars]
+ *
+ * 默认:仅扫描 choice + fill;输出 summary JSON + ndjson 明细。
+ * 「$ 个数奇偶」默认不测(题库脏数据多时可加 --check-unbalanced-dollars)。
+ * 重点排查项单独写入 *priority_issues*.ndjson(空位夹在双 $…$ 段之间、公式段以运算符结尾紧邻空位)。
+ */
+
+declare(strict_types=1);
+
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
+$kernel->bootstrap();
+
+use App\Support\BlankPlaceholderRenderer;
+use Illuminate\Support\Facades\DB;
+
+$options = getopt('', [
+    'table::',
+    'connection::',
+    'chunk::',
+    'out-dir::',
+    'types::',
+    'check-unbalanced-dollars::',
+]);
+
+$checkUnbalancedDollars = array_key_exists('check-unbalanced-dollars', $options);
+
+$table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
+$connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
+$chunk = isset($options['chunk']) ? max(100, (int) $options['chunk']) : 2000;
+$defaultOut = dirname(__DIR__).'/storage/app/audit_placeholder';
+$outDir = isset($options['out-dir']) ? rtrim((string) $options['out-dir'], '/') : $defaultOut;
+// 默认仅选择与填空(与用户需求一致);若要全题型可传 --types=all 并在下方解析
+$typeFilter = isset($options['types']) ? trim((string) $options['types']) : 'choice,fill';
+$types = [];
+if (strtolower($typeFilter) === 'all') {
+    $types = [];
+} elseif ($typeFilter !== '') {
+    $types = array_values(array_filter(array_map('trim', explode(',', $typeFilter)), static fn($v) => $v !== ''));
+}
+
+@mkdir($outDir, 0777, true);
+$stamp = date('Ymd_His');
+$summaryPath = "{$outDir}/rendered_placeholder_audit_summary_{$stamp}.json";
+$detailPath = "{$outDir}/rendered_placeholder_audit_details_{$stamp}.ndjson";
+$priorityDetailPath = "{$outDir}/rendered_placeholder_audit_priority_issues_{$stamp}.ndjson";
+
+$detailFp = fopen($detailPath, 'wb');
+if ($detailFp === false) {
+    fwrite(STDERR, "Failed to open detail file: {$detailPath}\n");
+    exit(1);
+}
+
+$priorityIssueTypes = [
+    'blank_between_math_segments',
+    'math_ends_with_operator_before_blank',
+];
+
+$priorityFp = fopen($priorityDetailPath, 'wb');
+if ($priorityFp === false) {
+    fwrite(STDERR, "Failed to open priority detail file: {$priorityDetailPath}\n");
+    exit(1);
+}
+
+$issues = [];
+$examples = [];
+
+$scanned = 0;
+$startedAt = microtime(true);
+
+$recordIssue = static function (string $type, object $row, string $reason, string $rendered) use (&$issues, &$examples, $detailFp, $priorityFp, $priorityIssueTypes): void {
+    if (! isset($issues[$type])) {
+        $issues[$type] = 0;
+        $examples[$type] = [];
+    }
+    $issues[$type]++;
+
+    $entry = [
+        'issue' => $type,
+        'id' => (int) $row->id,
+        'question_type' => (string) ($row->question_type ?? ''),
+        'reason' => $reason,
+        'stem_preview' => mb_substr((string) $row->stem, 0, 220),
+        'rendered_preview' => mb_substr($rendered, 0, 260),
+    ];
+
+    fwrite($detailFp, json_encode($entry, JSON_UNESCAPED_UNICODE)."\n");
+
+    if (in_array($type, $priorityIssueTypes, true)) {
+        fwrite($priorityFp, json_encode($entry, JSON_UNESCAPED_UNICODE)."\n");
+    }
+
+    if (count($examples[$type]) < 20) {
+        $examples[$type][] = [
+            'id' => (int) $row->id,
+            'question_type' => (string) ($row->question_type ?? ''),
+            'reason' => $reason,
+        ];
+    }
+};
+
+$blankSpan = BlankPlaceholderRenderer::defaultBlankSpan();
+$query = DB::connection($connection)
+    ->table($table)
+    ->select('id', 'question_type', 'stem')
+    ->whereNotNull('stem')
+    ->orderBy('id');
+
+if ($types !== []) {
+    $query->whereIn('question_type', $types);
+}
+
+$query->chunkById($chunk, function ($rows) use (&$scanned, $recordIssue, $blankSpan, $checkUnbalancedDollars): void {
+    foreach ($rows as $row) {
+        $stem = (string) $row->stem;
+        $type = strtolower(trim((string) ($row->question_type ?? '')));
+
+        [$rendered, $hasPlaceholders] = BlankPlaceholderRenderer::replaceToBlankSpan($stem, $blankSpan, false, false);
+
+        // 与当前 paper-body 渲染口径一致(只覆盖选择/填空)
+        if ($type === 'choice') {
+            $rendered = BlankPlaceholderRenderer::normalizeTerminalPunctuation($rendered, 'remove');
+        } elseif ($type === 'fill') {
+            if (! $hasPlaceholders) {
+                $rendered .= ' '.$blankSpan;
+            }
+            $rendered = BlankPlaceholderRenderer::normalizeTerminalPunctuation($rendered, 'dot');
+            $rendered = BlankPlaceholderRenderer::normalizePeriodBeforeTrailingParentheticalNote($rendered, '.');
+            $rendered = BlankPlaceholderRenderer::appendTerminalPunctuationIfMissing($rendered, '.');
+        }
+
+        // 1) 30949 类:\left( + 空位 + \right) 被拆成多个数学段
+        if (preg_match('/\$\\s*\\\\left[\\(\\[]\\s*\$\\s*<span[^>]*>.*?<\\/span>\\s*\$\\s*\\\\right[\\)\\]]\\s*\$/u', $rendered)) {
+            $recordIssue('broken_left_right_split', $row, 'left/right wrapped blank split into separate math segments', $rendered);
+        }
+
+        // 2) 空位夹在两个数学段中(高风险结构,常导致公式语义断裂)
+        if (preg_match('/\$[^$]*\$\\s*<span[^>]*>.*?<\\/span>\\s*\$[^$]*\$/u', $rendered)) {
+            $recordIssue('blank_between_math_segments', $row, 'blank span inserted between two $...$ segments', $rendered);
+        }
+
+        // 3) 渲染后「可见文本」里 $ 个数奇数 — 默认跳过(原始题干脏数据多);需要时加 --check-unbalanced-dollars
+        if ($checkUnbalancedDollars) {
+            $visibleForDollar = html_entity_decode(strip_tags($rendered), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            if ((substr_count($visibleForDollar, '$') % 2) !== 0) {
+                $recordIssue('unbalanced_dollar_after_render', $row, 'odd number of $ in visible text after rendering', $rendered);
+            }
+        }
+
+        // 4) 数学段在空位前以操作符结束(语义可能不完整)
+        if (preg_match('/\$[^$]*[=+\-×÷*\\\\cdot]\\s*\$\\s*<span[^>]*>.*?<\\/span>/u', $rendered)) {
+            $recordIssue('math_ends_with_operator_before_blank', $row, 'math segment ends with operator right before blank span', $rendered);
+        }
+
+        // 5) 2562 类回归:空位 span 后紧跟孤立 $ + 汉字(错误插 $)
+        if (preg_match('/<\\/span>\s*\$\s*[\p{Han}]/u', $rendered)) {
+            $recordIssue('span_then_dollar_before_han', $row, 'blank span followed by stray $ before Chinese (formula boundary break)', $rendered);
+        }
+
+        // 6) 占位 token 泄漏(不应出现在最终 HTML)
+        if (preg_match('/<<<|BLANK_IN_MATH|LATEX_BLANK|LR_PAIR_/u', $rendered)) {
+            $recordIssue('internal_placeholder_token_leak', $row, 'placeholder token not restored in output', $rendered);
+        }
+
+        $scanned++;
+        if (($scanned % 5000) === 0) {
+            fwrite(STDERR, "scanned={$scanned}\n");
+        }
+    }
+}, 'id');
+
+fclose($detailFp);
+fclose($priorityFp);
+
+$elapsed = round(microtime(true) - $startedAt, 3);
+
+$investigationFocus = [
+    'rules' => $priorityIssueTypes,
+    'issue_counts' => [
+        'blank_between_math_segments' => $issues['blank_between_math_segments'] ?? 0,
+        'math_ends_with_operator_before_blank' => $issues['math_ends_with_operator_before_blank'] ?? 0,
+    ],
+];
+
+$summary = [
+    'table' => $table,
+    'connection' => $connection,
+    'chunk' => $chunk,
+    'types_filter' => $types,
+    'scanned_rows' => $scanned,
+    'investigation_focus' => $investigationFocus,
+    'checks_disabled_by_default' => array_values(array_filter([
+        $checkUnbalancedDollars ? null : 'unbalanced_dollar_after_render ($ odd/even in visible text)',
+    ])),
+    'issue_counts' => $issues,
+    'example_ids' => array_map(static fn(array $list) => array_column($list, 'id'), array_filter($examples, 'is_array')),
+    'elapsed_seconds' => $elapsed,
+    'generated_at' => date('c'),
+    'detail_path' => $detailPath,
+    'priority_issues_detail_path' => $priorityDetailPath,
+];
+
+file_put_contents($summaryPath, json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+
+echo json_encode([
+    'summary_path' => $summaryPath,
+    'detail_path' => $detailPath,
+    'priority_issues_detail_path' => $priorityDetailPath,
+    'summary' => $summary,
+], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";

+ 203 - 0
scripts/compare_question_pdf_two_paths.php

@@ -0,0 +1,203 @@
+<?php
+
+/**
+ * 同一 questions.id:输出两份本地 PDF 供对比
+ *   A) 题目质检(pdf.question-check → generateQuestionCheckPdf)
+ *   B) 常规学生卷页面(pdf.exam-paper,含装订线与卷头,与组卷导出同源样式)
+ *
+ * 用法:
+ *   php scripts/compare_question_pdf_two_paths.php --id=34728
+ *       [--out-dir storage/app/audit_placeholder/pdf_compare]
+ */
+
+declare(strict_types=1);
+
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
+$kernel->bootstrap();
+
+use App\Services\ExamPdfExportService;
+use App\Services\KatexRenderer;
+use Illuminate\Support\Facades\DB;
+
+$options = getopt('', ['id::', 'out-dir::', 'connection::', 'table::']);
+
+$questionId = isset($options['id']) ? max(1, (int) $options['id']) : 34728;
+$connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
+$table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
+
+$outRoot = isset($options['out-dir']) ? rtrim((string) $options['out-dir'], '/') : dirname(__DIR__).'/storage/app/audit_placeholder/pdf_compare';
+if ($outRoot === '' || ($outRoot[0] !== '/' && ! preg_match('#^[A-Za-z]:[\\\\/]#', $outRoot))) {
+    $outRoot = dirname(__DIR__).'/'.ltrim($outRoot, '/');
+}
+$stamp = date('Ymd_His');
+$runDir = $outRoot.'/'.$stamp.'_q'.$questionId;
+if (! @mkdir($runDir, 0775, true) && ! is_dir($runDir)) {
+    fwrite(STDERR, "Cannot mkdir {$runDir}\n");
+    exit(1);
+}
+
+$row = DB::connection($connection)->table($table)->where('id', $questionId)->first();
+if ($row === null) {
+    fwrite(STDERR, "Question id {$questionId} not found.\n");
+    exit(1);
+}
+
+$questionMap = [(int) $row->id => $row];
+$grouped = groupQuestionsByType($questionMap, [$questionId]);
+$paper = buildVirtualPaper('PDF对比_'.$questionId, 'pdf_compare_'.$stamp, $grouped);
+
+/** @var ExamPdfExportService $pdfService */
+$pdfService = $app->make(ExamPdfExportService::class);
+
+// --- A 题目质检 ---
+$pdfMetaCheck = [
+    'student_name' => '对比',
+    'exam_code' => 'QC_'.$questionId,
+    'assemble_type_label' => '题目质检',
+    'header_title' => '对比|QC_'.$questionId.'|题目质检',
+    'exam_pdf_title' => '对比_QC_'.$questionId,
+    'grading_pdf_title' => '对比_QC_'.$questionId,
+    'knowledge_pdf_title' => '对比_QC_'.$questionId,
+];
+
+$htmlCheck = view('pdf.question-check', [
+    'paper' => $paper,
+    'questions' => $grouped,
+    'student' => ['name' => '对比', 'grade' => '________'],
+    'teacher' => ['name' => '________'],
+    'pdfMeta' => $pdfMetaCheck,
+])->render();
+
+$resultA = $pdfService->generateQuestionCheckPdf(
+    $paper,
+    $grouped,
+    ['name' => '对比', 'grade' => '________'],
+    ['name' => '________'],
+    $runDir.'/A_question_check.pdf'
+);
+
+if (($resultA['local_path'] ?? '') === '') {
+    fwrite(STDERR, "Path A failed: ".json_encode($resultA, JSON_UNESCAPED_UNICODE)."\n");
+    exit(1);
+}
+
+// --- B 常规试卷(学生卷,无答案)---
+$pdfMetaExam = [
+    'exam_pdf_title' => '对比_常规卷_'.$questionId,
+    'exam_code' => 'EX_'.$questionId,
+    'student_name' => '对比',
+    'header_title' => '对比|EX_'.$questionId.'|常规卷',
+    'assemble_type_label' => '样式对比',
+];
+
+$htmlExam = view('pdf.exam-paper', [
+    'paper' => $paper,
+    'questions' => $grouped,
+    'student' => ['name' => '对比', 'grade' => '________'],
+    'teacher' => ['name' => '________'],
+    'includeAnswer' => false,
+    'pdfMeta' => $pdfMetaExam,
+])->render();
+
+$katex = new KatexRenderer();
+$htmlExam = $katex->renderHtml($htmlExam);
+
+$ref = new ReflectionClass($pdfService);
+$mEnsure = $ref->getMethod('ensureUtf8Html');
+$mEnsure->setAccessible(true);
+$mBuild = $ref->getMethod('buildPdf');
+$mBuild->setAccessible(true);
+
+$htmlExamUtf8 = $mEnsure->invoke($pdfService, $htmlExam);
+$binaryExam = $mBuild->invoke($pdfService, $htmlExamUtf8, true);
+if ($binaryExam === null || $binaryExam === '') {
+    fwrite(STDERR, "Path B buildPdf returned empty.\n");
+    exit(1);
+}
+
+$pathB = $runDir.'/B_exam_paper_student.pdf';
+file_put_contents($pathB, $binaryExam);
+
+// 可选:保存 HTML 便于 diff
+file_put_contents($runDir.'/A_question_check.html', $htmlCheck);
+file_put_contents($runDir.'/B_exam_paper.html', $htmlExam);
+
+echo json_encode([
+    'question_id' => $questionId,
+    'output_directory' => $runDir,
+    'A_question_check_pdf' => $resultA['local_path'],
+    'B_exam_paper_pdf' => $pathB,
+    'notes' => 'A=题目质检模板;B=常规组卷学生卷模板(exam-paper)。题干区均用 components.exam.paper-body。',
+], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
+
+/**
+ * @param  array<int, object>  $questionMap
+ */
+function groupQuestionsByType(array $questionMap, array $originalOrder): array
+{
+    $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
+    $n = 1;
+    foreach ($originalOrder as $id) {
+        if (! isset($questionMap[$id])) {
+            continue;
+        }
+        $q = $questionMap[$id];
+        $type = normalizeQuestionType($q->question_type ?? null);
+        $grouped[$type][] = (object) [
+            'id' => $q->id,
+            'question_number' => $n++,
+            'content' => $q->stem,
+            'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
+            'answer' => $q->answer,
+            'solution' => $q->solution,
+            'score' => match ($type) {
+                'choice', 'fill' => 5,
+                default => 10,
+            },
+            'difficulty' => $q->difficulty,
+            'kp_code' => $q->kp_code,
+        ];
+    }
+
+    return $grouped;
+}
+
+function normalizeQuestionType(?string $type): string
+{
+    if (! $type) {
+        return 'answer';
+    }
+    $type = strtolower(trim($type));
+    $map = [
+        'choice' => 'choice', '选择题' => 'choice', 'single_choice' => 'choice', 'multiple_choice' => 'choice',
+        'fill' => 'fill', '填空题' => 'fill', 'blank' => 'fill',
+        'answer' => 'answer', '解答题' => 'answer', 'subjective' => 'answer',
+    ];
+
+    return $map[$type] ?? 'answer';
+}
+
+function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
+{
+    $totalScore = 0;
+    $totalQuestions = 0;
+    foreach ($groupedQuestions as $questions) {
+        foreach ($questions as $q) {
+            $totalScore += $q->score;
+            $totalQuestions++;
+        }
+    }
+
+    // PaperNaming::extractExamCode 要求可解析为合法 15 位考试编码
+    $paperId = 'paper_100000000000001';
+
+    return (object) [
+        'paper_id' => $paperId,
+        'paper_name' => $paperName,
+        'total_score' => $totalScore,
+        'total_questions' => $totalQuestions,
+        'created_at' => now()->toDateTimeString(),
+    ];
+}

+ 276 - 0
scripts/dump_priority_issue_pdfs_local.php

@@ -0,0 +1,276 @@
+<?php
+
+/**
+ * 将审计 priority 明细里的题目分批导出为本地 PDF(题目质检模板),不上传 CDN。
+ *
+ * 用法:
+ *   php scripts/dump_priority_issue_pdfs_local.php [--per-pdf 150]
+ *       [--file storage/app/audit_placeholder/rendered_placeholder_audit_priority_issues_*.ndjson]
+ *       [--out-dir storage/app/audit_placeholder/local_priority_pdfs]
+ *       [--connection mysql] [--table questions]
+ *
+ * 不指定 --file 时,取 storage/app/audit_placeholder 下最新的 priority_issues ndjson。
+ * 每份 PDF 内题量由 --per-pdf 控制(默认 150,可改 50/200 等)。
+ */
+
+declare(strict_types=1);
+
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
+$kernel->bootstrap();
+
+use App\Services\ExamPdfExportService;
+use App\Support\PaperNaming;
+use Illuminate\Support\Facades\DB;
+
+$options = getopt('', [
+    'per-pdf::',
+    'file::',
+    'out-dir::',
+    'connection::',
+    'table::',
+]);
+
+$perPdf = isset($options['per-pdf']) ? max(1, (int) $options['per-pdf']) : 150;
+$connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
+$table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
+
+$defaultOut = dirname(__DIR__).'/storage/app/audit_placeholder/local_priority_pdfs';
+$outDir = isset($options['out-dir']) ? rtrim((string) $options['out-dir'], '/') : $defaultOut;
+if ($outDir[0] !== '/') {
+    $outDir = dirname(__DIR__).'/'.$outDir;
+}
+
+$ndjsonPath = isset($options['file']) ? trim((string) $options['file']) : '';
+if ($ndjsonPath === '') {
+    $auditDir = dirname(__DIR__).'/storage/app/audit_placeholder';
+    $glob = glob($auditDir.'/rendered_placeholder_audit_priority_issues_*.ndjson') ?: [];
+    if ($glob === []) {
+        fwrite(STDERR, "No priority ndjson under {$auditDir}. Run audit script first.\n");
+        exit(1);
+    }
+    usort($glob, static fn(string $a, string $b): int => strcmp($b, $a));
+    $ndjsonPath = $glob[0];
+}
+
+if (! is_readable($ndjsonPath)) {
+    fwrite(STDERR, "Cannot read: {$ndjsonPath}\n");
+    exit(1);
+}
+
+$orderedUniqueIds = [];
+$seen = [];
+$fh = fopen($ndjsonPath, 'rb');
+if ($fh === false) {
+    fwrite(STDERR, "Failed to open {$ndjsonPath}\n");
+    exit(1);
+}
+while (($line = fgets($fh)) !== false) {
+    $line = trim($line);
+    if ($line === '') {
+        continue;
+    }
+    $row = json_decode($line, true);
+    if (! is_array($row) || ! isset($row['id'])) {
+        continue;
+    }
+    $id = (int) $row['id'];
+    if ($id <= 0 || isset($seen[$id])) {
+        continue;
+    }
+    $seen[$id] = true;
+    $orderedUniqueIds[] = $id;
+}
+fclose($fh);
+
+if ($orderedUniqueIds === []) {
+    fwrite(STDERR, "No question ids in {$ndjsonPath}\n");
+    exit(1);
+}
+
+$batches = array_chunk($orderedUniqueIds, $perPdf);
+$stamp = date('Ymd_His');
+$runDir = $outDir.'/'.$stamp;
+if (! @mkdir($runDir, 0775, true) && ! is_dir($runDir)) {
+    fwrite(STDERR, "Cannot mkdir {$runDir}\n");
+    exit(1);
+}
+
+/** @var ExamPdfExportService $pdfService */
+$pdfService = $app->make(ExamPdfExportService::class);
+
+$written = [];
+$batchIndex = 0;
+foreach ($batches as $chunk) {
+    $batchIndex++;
+    $questions = DB::connection($connection)
+        ->table($table)
+        ->whereIn('id', $chunk)
+        ->get();
+
+    $questionMap = [];
+    foreach ($questions as $q) {
+        $questionMap[(int) $q->id] = $q;
+    }
+
+    $groupedQuestions = groupQuestionsByType($questionMap, $chunk);
+    $firstId = $chunk[0];
+    $lastId = $chunk[count($chunk) - 1];
+    $safeTitle = PaperNaming::toSafeFilename(
+        'priority_issues_batch_'.$batchIndex.'_'.count($chunk).'qs_'.$firstId.'-'.$lastId
+    );
+    $filename = $safeTitle.'.pdf';
+    $localPath = $runDir.'/'.$filename;
+
+    $paper = buildVirtualPaper(
+        '重点排查 PDF 第 '.$batchIndex.'/'.count($batches).' 批('.count($chunk).' 题)',
+        'priority_local_'.$stamp.'_b'.$batchIndex,
+        $groupedQuestions
+    );
+
+    $result = $pdfService->generateQuestionCheckPdf(
+        $paper,
+        $groupedQuestions,
+        ['name' => '本地导出', 'grade' => '________'],
+        ['name' => '________'],
+        $localPath
+    );
+
+    if (($result['local_path'] ?? '') === '') {
+        fwrite(STDERR, "FAILED batch {$batchIndex}: ".json_encode($result, JSON_UNESCAPED_UNICODE)."\n");
+        exit(1);
+    }
+
+    $written[] = [
+        'batch' => $batchIndex,
+        'count' => count($chunk),
+        'ids_range' => [$firstId, $lastId],
+        'local_path' => $localPath,
+        'bytes' => @filesize($localPath) ?: 0,
+    ];
+
+    fwrite(STDERR, "wrote batch {$batchIndex}/".count($batches)." → {$filename}\n");
+}
+
+$manifestPath = $runDir.'/manifest.json';
+file_put_contents($manifestPath, json_encode([
+    'generated_at' => date('c'),
+    'source_ndjson' => realpath($ndjsonPath) ?: $ndjsonPath,
+    'connection' => $connection,
+    'table' => $table,
+    'per_pdf' => $perPdf,
+    'total_questions' => count($orderedUniqueIds),
+    'batch_count' => count($batches),
+    'output_directory' => $runDir,
+    'batches' => $written,
+], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+
+echo json_encode([
+    'output_directory' => $runDir,
+    'manifest_path' => $manifestPath,
+    'batch_count' => count($written),
+    'total_questions' => count($orderedUniqueIds),
+    'batches' => $written,
+], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
+
+/**
+ * @param  array<int, object>  $questionMap
+ */
+function groupQuestionsByType(array $questionMap, array $originalOrder): array
+{
+    $grouped = [
+        'choice' => [],
+        'fill' => [],
+        'answer' => [],
+    ];
+
+    $questionNumber = 1;
+
+    foreach ($originalOrder as $id) {
+        if (! isset($questionMap[$id])) {
+            continue;
+        }
+
+        $q = $questionMap[$id];
+        $type = normalizeQuestionType($q->question_type ?? null);
+
+        $questionObj = (object) [
+            'id' => $q->id,
+            'question_number' => $questionNumber++,
+            'content' => $q->stem,
+            'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
+            'answer' => $q->answer,
+            'solution' => $q->solution,
+            'score' => getDefaultScore($type),
+            'difficulty' => $q->difficulty,
+            'kp_code' => $q->kp_code,
+        ];
+
+        $grouped[$type][] = $questionObj;
+    }
+
+    return $grouped;
+}
+
+function normalizeQuestionType(?string $type): string
+{
+    if (! $type) {
+        return 'answer';
+    }
+
+    $type = strtolower(trim($type));
+
+    $typeMap = [
+        'choice' => 'choice',
+        '选择题' => 'choice',
+        'single_choice' => 'choice',
+        'multiple_choice' => 'choice',
+        'fill' => 'fill',
+        '填空题' => 'fill',
+        'blank' => 'fill',
+        'answer' => 'answer',
+        '解答题' => 'answer',
+        'subjective' => 'answer',
+        'calculation' => 'answer',
+        'proof' => 'answer',
+    ];
+
+    return $typeMap[$type] ?? 'answer';
+}
+
+function getDefaultScore(string $type): int
+{
+    return match ($type) {
+        'choice' => 5,
+        'fill' => 5,
+        'answer' => 10,
+        default => 5,
+    };
+}
+
+/**
+ * @param  array<string, array<int, object>>  $groupedQuestions
+ */
+function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
+{
+    $totalScore = 0;
+    $totalQuestions = 0;
+
+    foreach ($groupedQuestions as $questions) {
+        foreach ($questions as $q) {
+            $totalScore += $q->score;
+            $totalQuestions++;
+        }
+    }
+
+    $paperId = $studentId.'_'.uniqid();
+
+    return (object) [
+        'paper_id' => $paperId,
+        'paper_name' => $paperName,
+        'total_score' => $totalScore,
+        'total_questions' => $totalQuestions,
+        'created_at' => now()->toDateTimeString(),
+    ];
+}

+ 283 - 0
scripts/generate_sample_placeholder_audit_pdf.php

@@ -0,0 +1,283 @@
+<?php
+
+/**
+ * 从占位符审计明细(ndjson)中按类型轮询抽样 N 道题,生成「题目质检」PDF(与 POST /api/questions/pdf 同源逻辑)。
+ *
+ * 用法:
+ *   php scripts/generate_sample_placeholder_audit_pdf.php [--count 30]
+ *       [--detail storage/app/audit_placeholder/rendered_placeholder_audit_details_*.ndjson]
+ *       [--connection mysql] [--table questions]
+ *
+ * 若不指定 --detail,则自动选用 storage/app/audit_placeholder 下最新的 rendered_placeholder_audit_details_*.ndjson。
+ *
+ * 抽样优先级(轮询):
+ *   internal_placeholder_token_leak → broken_left_right_split → span_then_dollar_before_han
+ *   → blank_between_math_segments → unbalanced_dollar_after_render → math_ends_with_operator_before_blank
+ */
+
+declare(strict_types=1);
+
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
+$kernel->bootstrap();
+
+use App\Services\ExamPdfExportService;
+use Illuminate\Support\Facades\DB;
+
+$options = getopt('', [
+    'count::',
+    'detail::',
+    'connection::',
+    'table::',
+]);
+
+$count = isset($options['count']) ? max(1, min(100, (int) $options['count'])) : 30;
+$connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
+$table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
+
+$detailPath = isset($options['detail']) ? trim((string) $options['detail']) : '';
+if ($detailPath === '') {
+    $auditDir = dirname(__DIR__).'/storage/app/audit_placeholder';
+    $glob = glob($auditDir.'/rendered_placeholder_audit_details_*.ndjson') ?: [];
+    if ($glob === []) {
+        fwrite(STDERR, "No detail ndjson found under {$auditDir}. Run scripts/audit_rendered_placeholder_integrity.php first.\n");
+        exit(1);
+    }
+    usort($glob, static fn(string $a, string $b): int => strcmp($b, $a));
+    $detailPath = $glob[0];
+}
+
+if (! is_readable($detailPath)) {
+    fwrite(STDERR, "Cannot read detail file: {$detailPath}\n");
+    exit(1);
+}
+
+$priorityTypes = [
+    'internal_placeholder_token_leak',
+    'broken_left_right_split',
+    'span_then_dollar_before_han',
+    'blank_between_math_segments',
+    'unbalanced_dollar_after_render',
+    'math_ends_with_operator_before_blank',
+];
+
+$buckets = array_fill_keys($priorityTypes, []);
+
+$fh = fopen($detailPath, 'rb');
+if ($fh === false) {
+    fwrite(STDERR, "Failed to open {$detailPath}\n");
+    exit(1);
+}
+while (($line = fgets($fh)) !== false) {
+    $line = trim($line);
+    if ($line === '') {
+        continue;
+    }
+    $row = json_decode($line, true);
+    if (! is_array($row) || ! isset($row['issue'], $row['id'])) {
+        continue;
+    }
+    $issue = (string) $row['issue'];
+    $id = (int) $row['id'];
+    if ($id <= 0 || ! isset($buckets[$issue])) {
+        continue;
+    }
+    $buckets[$issue][] = $id;
+}
+fclose($fh);
+
+foreach ($buckets as $issue => &$ids) {
+    $seen = [];
+    $unique = [];
+    foreach ($ids as $id) {
+        if (! isset($seen[$id])) {
+            $seen[$id] = true;
+            $unique[] = $id;
+        }
+    }
+    $ids = $unique;
+}
+unset($ids);
+
+$selectedOrder = [];
+$selectedSet = [];
+
+while (count($selectedOrder) < $count) {
+    $progress = false;
+    foreach ($priorityTypes as $type) {
+        if (count($selectedOrder) >= $count) {
+            break 2;
+        }
+        while ($buckets[$type] !== []) {
+            $id = array_shift($buckets[$type]);
+            if (! isset($selectedSet[$id])) {
+                $selectedSet[$id] = true;
+                $selectedOrder[] = $id;
+                $progress = true;
+                break;
+            }
+        }
+    }
+    if (! $progress) {
+        break;
+    }
+}
+
+if ($selectedOrder === []) {
+    fwrite(STDERR, "No issue rows parsed from {$detailPath}; nothing to sample.\n");
+    exit(1);
+}
+
+if (count($selectedOrder) < $count) {
+    fwrite(STDERR, 'warning: only '.count($selectedOrder)." unique ids available (requested {$count}).\n");
+}
+
+$questions = DB::connection($connection)
+    ->table($table)
+    ->whereIn('id', $selectedOrder)
+    ->get();
+
+$questionMap = [];
+foreach ($questions as $q) {
+    $questionMap[(int) $q->id] = $q;
+}
+
+$missing = array_values(array_filter($selectedOrder, static fn(int $id): bool => ! isset($questionMap[$id])));
+if ($missing !== []) {
+    fwrite(STDERR, 'warning: ids not found in '.$table.' ('.$connection.'): '.implode(',', $missing)."\n");
+}
+
+$groupedQuestions = groupQuestionsByType($questionMap, $selectedOrder);
+$paper = buildVirtualPaper('占位符抽样质检_'.count($selectedOrder).'题', 'placeholder_audit_sample', $groupedQuestions);
+
+/** @var ExamPdfExportService $pdf */
+$pdf = $app->make(ExamPdfExportService::class);
+$result = $pdf->generateQuestionCheckPdf(
+    $paper,
+    $groupedQuestions,
+    ['name' => '质检抽样', 'grade' => '________'],
+    ['name' => '________']
+);
+
+$manifestPath = dirname(__DIR__).'/storage/app/audit_placeholder/sample_pdf_manifest_'.date('Ymd_His').'.json';
+$manifest = [
+    'generated_at' => date('c'),
+    'detail_source' => $detailPath,
+    'connection' => $connection,
+    'table' => $table,
+    'requested_count' => $count,
+    'sampled_question_ids' => $selectedOrder,
+    'pdf_result' => $result,
+];
+
+@mkdir(dirname($manifestPath), 0777, true);
+file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+
+echo json_encode([
+    'detail_source' => $detailPath,
+    'manifest_path' => $manifestPath,
+    'sampled_ids' => $selectedOrder,
+    'pdf' => $result,
+], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
+
+/**
+ * @param  array<int, object>  $questionMap
+ */
+function groupQuestionsByType(array $questionMap, array $originalOrder): array
+{
+    $grouped = [
+        'choice' => [],
+        'fill' => [],
+        'answer' => [],
+    ];
+
+    $questionNumber = 1;
+
+    foreach ($originalOrder as $id) {
+        if (! isset($questionMap[$id])) {
+            continue;
+        }
+
+        $q = $questionMap[$id];
+        $type = normalizeQuestionType($q->question_type ?? null);
+
+        $questionObj = (object) [
+            'id' => $q->id,
+            'question_number' => $questionNumber++,
+            'content' => $q->stem,
+            'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
+            'answer' => $q->answer,
+            'solution' => $q->solution,
+            'score' => getDefaultScore($type),
+            'difficulty' => $q->difficulty,
+            'kp_code' => $q->kp_code,
+        ];
+
+        $grouped[$type][] = $questionObj;
+    }
+
+    return $grouped;
+}
+
+function normalizeQuestionType(?string $type): string
+{
+    if (! $type) {
+        return 'answer';
+    }
+
+    $type = strtolower(trim($type));
+
+    $typeMap = [
+        'choice' => 'choice',
+        '选择题' => 'choice',
+        'single_choice' => 'choice',
+        'multiple_choice' => 'choice',
+        'fill' => 'fill',
+        '填空题' => 'fill',
+        'blank' => 'fill',
+        'answer' => 'answer',
+        '解答题' => 'answer',
+        'subjective' => 'answer',
+        'calculation' => 'answer',
+        'proof' => 'answer',
+    ];
+
+    return $typeMap[$type] ?? 'answer';
+}
+
+function getDefaultScore(string $type): int
+{
+    return match ($type) {
+        'choice' => 5,
+        'fill' => 5,
+        'answer' => 10,
+        default => 5,
+    };
+}
+
+/**
+ * @param  array<string, array<int, object>>  $groupedQuestions
+ */
+function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
+{
+    $totalScore = 0;
+    $totalQuestions = 0;
+
+    foreach ($groupedQuestions as $questions) {
+        foreach ($questions as $q) {
+            $totalScore += $q->score;
+            $totalQuestions++;
+        }
+    }
+
+    $paperId = $studentId.'_'.time().'_'.uniqid();
+
+    return (object) [
+        'paper_id' => $paperId,
+        'paper_name' => $paperName,
+        'total_score' => $totalScore,
+        'total_questions' => $totalQuestions,
+        'created_at' => now()->toDateTimeString(),
+    ];
+}

+ 111 - 0
tests/Unit/BlankPlaceholderRendererTest.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Support\BlankPlaceholderRenderer;
+use PHPUnit\Framework\TestCase;
+
+class BlankPlaceholderRendererTest extends TestCase
+{
+    public function test_fill_stem_canonical_2562_dollar_around_a_only(): void
+    {
+        // questions.id=2562 本源:$a$ 与横线分段,横线在 $…$ 外;不得出现 </span>$时 这类误插 $。
+        $stem = '当$a$=__________时,$2(2a-3)$的值比$3(a+1)$的值大$1$.';
+
+        [$rendered, $changed] = BlankPlaceholderRenderer::replaceToBlankSpan(
+            $stem,
+            BlankPlaceholderRenderer::defaultBlankSpan(),
+            false,
+            false
+        );
+
+        $this->assertTrue($changed);
+        $this->assertStringNotContainsString('</span>$时', $rendered);
+        $this->assertMatchesRegularExpression('/\$2\(2a-3\)\$/u', $rendered);
+        $this->assertMatchesRegularExpression('/当\$a\$=/u', $rendered);
+    }
+
+    public function test_fill_stem_malformed_missing_close_dollar_before_han_still_repaired(): void
+    {
+        // 脏数据:仅 $a=__________($a 与横线同一数学段且漏写收尾 $)时补闭合,避免吞段。
+        $stem = '当$a=__________时,$2(2a-3)$的值比$3(a+1)$的值大$1$.';
+
+        [$rendered, $changed] = BlankPlaceholderRenderer::replaceToBlankSpan(
+            $stem,
+            BlankPlaceholderRenderer::defaultBlankSpan(),
+            false,
+            false
+        );
+
+        $this->assertTrue($changed);
+        $this->assertStringNotContainsString('</span>$时', $rendered);
+        $this->assertMatchesRegularExpression('/\$2\(2a-3\)\$/u', $rendered);
+    }
+
+    public function test_inline_math_with_normal_parentheses_not_turned_to_blank(): void
+    {
+        $stem = '设$f(x)=\sin(x)$,则$g(2)$的值是__________。';
+
+        [$rendered] = BlankPlaceholderRenderer::replaceToBlankSpan(
+            $stem,
+            BlankPlaceholderRenderer::defaultBlankSpan(),
+            false,
+            false
+        );
+
+        $this->assertStringContainsString('sin(x)', $rendered);
+        $this->assertStringContainsString('g(2)', $rendered);
+        $this->assertStringContainsString('border-bottom:1.2px dashed', $rendered);
+    }
+
+    public function test_left_quad_right_not_at_suffix_stays_in_formula(): void
+    {
+        // 段末不是「…=\left(\quad\right)」完整后缀时,不转为下划线(避免误伤中段记号)。
+        $stem = '若$x=\left(\quad\right)+1$,则$y=__________。';
+
+        [$rendered] = BlankPlaceholderRenderer::replaceToBlankSpan(
+            $stem,
+            BlankPlaceholderRenderer::defaultBlankSpan(),
+            false,
+            false
+        );
+
+        $this->assertStringContainsString('\\left(\\quad\\right)', $rendered);
+    }
+
+    public function test_fill_degree_after_plain_blank_not_prefixed_with_stray_dollar(): void
+    {
+        // questions.id=332:inline $40^{\circ}$ 后又出现「向左拐______度」,中间无 $;
+        // closeMissing 不得误用上一段公式的收尾 $,否则会出现「……横线 $度」。
+        $stem = '一辆汽车在笔直的公路上行驶,第一次向左拐 $40^{\circ}$,若经第二次拐弯后,运动路线与原来的路线平行,则第二次拐弯是否向右拐或向左拐______度。';
+
+        [$rendered] = BlankPlaceholderRenderer::replaceToBlankSpan(
+            $stem,
+            BlankPlaceholderRenderer::defaultBlankSpan(),
+            false,
+            false
+        );
+
+        $this->assertStringContainsString('</span>度', $rendered);
+        $this->assertStringNotContainsString('</span>$度', $rendered);
+    }
+
+    public function test_choice_stem_trailing_left_quad_right_becomes_blank_line(): void
+    {
+        // questions.id=30949:段末 $=\left(\quad\right)$ 意图为答题横线,转为段外下划线再渲染为标准空位。
+        $stem = '在三角形$ABC$中,$AC=3,AB=4,\angle CAB=120^{\circ}$,则$\left(\overrightarrow{AB}+\overrightarrow{AC}\right)\cdot\overrightarrow{AB}=\left(\quad\right)$';
+
+        [$rendered] = BlankPlaceholderRenderer::replaceToBlankSpan(
+            $stem,
+            BlankPlaceholderRenderer::defaultBlankSpan(),
+            false,
+            false
+        );
+
+        $this->assertStringContainsString('border-bottom:1.2px dashed', $rendered);
+        $this->assertStringContainsString('min-width:80px', $rendered);
+        $this->assertStringNotContainsString('\\left(\\quad\\right)', $rendered);
+        $this->assertStringNotContainsString('TOKEN', $rendered);
+        $this->assertStringContainsString('\\overrightarrow{AB}', $rendered);
+    }
+}

+ 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}'));
     }
 }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません