Prechádzať zdrojové kódy

merge: ye/fix-choice-option-layout

yemeishu 1 týždeň pred
rodič
commit
54d6999ab3

+ 293 - 0
app/Console/Commands/GenerateOptionLayoutRegressionCommand.php

@@ -0,0 +1,293 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\ExamPdfExportService;
+use App\Support\OptionLayoutDecider;
+use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\File;
+
+class GenerateOptionLayoutRegressionCommand extends Command
+{
+    protected $signature = 'exam:generate-option-layout-regression
+        {--connection=remote_mysql : 数据库连接}
+        {--source=main : 题库来源 main|default|ai}
+        {--question-type=choice : 题型过滤 choice|fill|answer|all}
+        {--keep-ids= : 固定保留题目ID,逗号分隔,例如 38848,38772,38869}
+        {--limit=40 : 抽题数量}
+        {--grading=1 : 是否同时生成判卷PDF 1|0}
+        {--student-id=REGRESSION : 学生ID}
+        {--student-name=排版回归 : 学生姓名}
+        {--student-grade=初三 : 学生年级}
+        {--teacher-name=回归老师 : 教师姓名}
+        {--paper-name=选项排版回归 : 试卷名称}
+        {--report= : 报告输出路径(JSON)}';
+
+    protected $description = '遍历题库复杂公式题,生成真实PDF并输出选项布局回归报告';
+
+    public function handle(
+        ExamPdfExportService $pdfExportService,
+        OptionLayoutDecider $layoutDecider
+    ): int {
+        $connection = (string) $this->option('connection');
+        $source = (string) $this->option('source');
+        $limit = max(1, (int) $this->option('limit'));
+        $questionType = (string) $this->option('question-type');
+        $keepIds = $this->parseKeepIds((string) $this->option('keep-ids'));
+        $includeGrading = (string) $this->option('grading') !== '0';
+
+        $table = match ($source) {
+            'default' => 'questions_tem',
+            'ai' => 'questions_ai',
+            default => 'questions',
+        };
+
+        $this->info("开始回归抽题: {$connection}.{$table}, type={$questionType}, limit={$limit}, keep=".count($keepIds));
+
+        $questions = $this->fetchComplexQuestions($connection, $table, $limit, $questionType, $keepIds);
+        if ($questions->isEmpty()) {
+            $this->warn('未找到复杂公式题,请调整筛选条件或检查题库数据');
+
+            return self::FAILURE;
+        }
+
+        $groupedQuestions = $this->groupQuestionsByType($questions);
+        $paperId = 'layout_regression_'.date('YmdHis').'_'.substr(uniqid('', true), -6);
+        $paper = $this->buildVirtualPaper(
+            $paperId,
+            (string) $this->option('paper-name'),
+            $groupedQuestions
+        );
+
+        $student = [
+            'id' => (string) $this->option('student-id'),
+            'name' => (string) $this->option('student-name'),
+            'grade' => (string) $this->option('student-grade'),
+        ];
+        $teacher = ['name' => (string) $this->option('teacher-name')];
+
+        $result = $pdfExportService->generateByQuestions(
+            $paper,
+            $groupedQuestions,
+            $student,
+            $teacher,
+            $includeGrading
+        );
+
+        $reportPath = (string) ($this->option('report') ?: storage_path('app/regression/option-layout-'.$paperId.'.json'));
+        File::ensureDirectoryExists(dirname($reportPath));
+        File::put($reportPath, json_encode([
+            'generated_at' => now()->toDateTimeString(),
+            'paper_id' => $paperId,
+            'source' => [
+                'connection' => $connection,
+                'table' => $table,
+                'keep_ids' => $keepIds,
+            ],
+            'summary' => [
+                'question_count' => $questions->count(),
+                'choice_count' => count($groupedQuestions['choice']),
+                'fill_count' => count($groupedQuestions['fill']),
+                'answer_count' => count($groupedQuestions['answer']),
+            ],
+            'pdfs' => $result,
+            'layout_checks' => $this->buildLayoutChecks($groupedQuestions, $layoutDecider),
+        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL);
+
+        $this->info('回归PDF生成完成');
+        $this->line('Exam PDF: '.($result['pdf_url'] ?? 'N/A'));
+        $this->line('Grading PDF: '.($result['grading_pdf_url'] ?? 'N/A'));
+        $this->line('Report JSON: '.$reportPath);
+
+        return self::SUCCESS;
+    }
+
+    private function fetchComplexQuestions(string $connection, string $table, int $limit, string $questionType, array $keepIds): Collection
+    {
+        $kept = collect();
+        if (! empty($keepIds)) {
+            $keptRaw = DB::connection($connection)
+                ->table($table)
+                ->select(['id', 'question_type', 'kp_code', 'stem', 'options', 'answer', 'solution', 'difficulty'])
+                ->whereIn('id', $keepIds)
+                ->get()
+                ->keyBy('id');
+
+            foreach ($keepIds as $id) {
+                if ($keptRaw->has($id)) {
+                    $kept->push($keptRaw->get($id));
+                }
+            }
+        }
+
+        $remainingLimit = max(0, $limit - $kept->count());
+
+        $query = DB::connection($connection)
+            ->table($table)
+            ->select(['id', 'question_type', 'kp_code', 'stem', 'options', 'answer', 'solution', 'difficulty'])
+            ->when($questionType !== 'all', function ($q) use ($questionType) {
+                $q->where('question_type', $questionType);
+            })
+            ->when($questionType === 'choice', function ($q) {
+                $q->whereNotNull('options')
+                    ->where('options', '!=', '')
+                    ->where('options', '!=', '[]')
+                    ->where('options', '!=', '{}');
+            })
+            ->where(function ($query) {
+                $query->where('stem', 'like', '%\\frac%')
+                    ->orWhere('stem', 'like', '%\\sqrt%')
+                    ->orWhere('stem', 'like', '%\\log%')
+                    ->orWhere('stem', 'like', '%\\sin%')
+                    ->orWhere('stem', 'like', '%\\cos%')
+                    ->orWhere('stem', 'like', '%\\tan%')
+                    ->orWhere('stem', 'like', '%<img%')
+                    ->orWhere('options', 'like', '%\\frac%')
+                    ->orWhere('options', 'like', '%\\sqrt%')
+                    ->orWhere('options', 'like', '%\\log%')
+                    ->orWhere('options', 'like', '%\\sin%')
+                    ->orWhere('options', 'like', '%\\cos%')
+                    ->orWhere('options', 'like', '%\\tan%')
+                    ->orWhere('options', 'like', '%^%')
+                    ->orWhere('options', 'like', '%/%');
+            })
+            ->when(! empty($keepIds), function ($q) use ($keepIds) {
+                $q->whereNotIn('id', $keepIds);
+            })
+            ->orderByDesc('id');
+
+        $sampled = $remainingLimit > 0 ? $query->limit($remainingLimit)->get() : collect();
+
+        return $kept->concat($sampled)->values();
+    }
+
+    private function groupQuestionsByType(Collection $questions): array
+    {
+        $grouped = [
+            'choice' => [],
+            'fill' => [],
+            'answer' => [],
+        ];
+
+        $questionNumber = 1;
+        foreach ($questions as $q) {
+            $type = $this->normalizeQuestionType($q->question_type);
+            $grouped[$type][] = (object) [
+                'id' => $q->id,
+                'question_number' => $questionNumber++,
+                'content' => $q->stem,
+                'options' => $this->normalizeOptions($q->options),
+                'answer' => $q->answer,
+                'solution' => $q->solution,
+                'score' => $this->defaultScore($type),
+                'difficulty' => $q->difficulty,
+                'kp_code' => $q->kp_code,
+            ];
+        }
+
+        return $grouped;
+    }
+
+    private function normalizeQuestionType(?string $type): string
+    {
+        $type = strtolower(trim((string) $type));
+
+        return match ($type) {
+            'choice', '选择题', 'single_choice', 'multiple_choice' => 'choice',
+            'fill', '填空题', 'blank' => 'fill',
+            default => 'answer',
+        };
+    }
+
+    private function normalizeOptions(mixed $options): array
+    {
+        if (is_array($options)) {
+            return array_values($options);
+        }
+
+        if (is_string($options) && trim($options) !== '') {
+            $decoded = json_decode($options, true);
+            if (is_array($decoded)) {
+                return array_values($decoded);
+            }
+        }
+
+        return [];
+    }
+
+    private function defaultScore(string $type): int
+    {
+        return match ($type) {
+            'answer' => 10,
+            default => 5,
+        };
+    }
+
+    private function buildVirtualPaper(string $paperId, string $paperName, array $groupedQuestions): object
+    {
+        $totalScore = 0;
+        $totalQuestions = 0;
+        foreach ($groupedQuestions as $items) {
+            foreach ($items as $item) {
+                $totalScore += (int) ($item->score ?? 0);
+                $totalQuestions++;
+            }
+        }
+
+        return (object) [
+            'paper_id' => $paperId,
+            'paper_name' => $paperName,
+            'total_score' => $totalScore,
+            'total_questions' => $totalQuestions,
+            'created_at' => now()->toDateTimeString(),
+        ];
+    }
+
+    private function buildLayoutChecks(array $groupedQuestions, OptionLayoutDecider $layoutDecider): array
+    {
+        $rows = [];
+        foreach (['choice', 'fill', 'answer'] as $type) {
+            foreach ($groupedQuestions[$type] as $question) {
+                $options = is_array($question->options ?? null) ? $question->options : [];
+                if ($type !== 'choice' || empty($options)) {
+                    continue;
+                }
+
+                $examDecision = $layoutDecider->decide($options, 'exam');
+                $gradingDecision = $layoutDecider->decide($options, 'grading');
+
+                $rows[] = [
+                    'question_id' => $question->id,
+                    'question_number' => $question->question_number,
+                    'kp_code' => $question->kp_code,
+                    'options_count' => count($options),
+                    'exam_layout' => $examDecision['class'],
+                    'grading_layout' => $gradingDecision['class'],
+                    'max_length_exam' => $examDecision['max_length'],
+                    'has_complex_formula' => $examDecision['has_complex_formula'],
+                ];
+            }
+        }
+
+        return $rows;
+    }
+
+    /**
+     * @return array<int>
+     */
+    private function parseKeepIds(string $keepIds): array
+    {
+        if (trim($keepIds) === '') {
+            return [];
+        }
+
+        return collect(explode(',', $keepIds))
+            ->map(fn ($id) => (int) trim($id))
+            ->filter(fn ($id) => $id > 0)
+            ->unique()
+            ->values()
+            ->all();
+    }
+}

+ 3 - 0
app/Services/ExamTypeStrategy.php

@@ -809,6 +809,9 @@ class ExamTypeStrategy
             // 使用 StudentKnowledgeMastery 模型获取掌握度低于阈值的知识点
             $weaknessRecords = StudentKnowledgeMastery::forStudent($studentId)
                 ->weaknesses($threshold)
+                ->whereHas('knowledgePoint', function ($query) {
+                    $query->whereNotNull('parent_kp_code'); // 排除根节点(如 M00/S000_000)
+                })
                 ->orderByMastery('asc')
                 ->limit(20)
                 ->with('knowledgePoint') // 预加载知识点信息

+ 3 - 0
app/Services/LearningAnalyticsService.php

@@ -1096,8 +1096,10 @@ class LearningAnalyticsService
         try {
             // 优先从 student_knowledge_mastery 表读取(更完整的掌握度数据)
             $weaknesses = DB::table('student_knowledge_mastery as skm')
+                ->join('knowledge_points as kp', 'kp.kp_code', '=', 'skm.kp_code')
                 ->where('skm.student_id', $studentId)
                 ->where('skm.mastery_level', '<', 0.9) // 掌握度低于70%视为薄弱点
+                ->whereNotNull('kp.parent_kp_code') // 排除根节点(如 M00/S000_000)
                 ->orderBy('skm.mastery_level', 'asc')
                 ->limit($limit)
                 ->select([
@@ -1122,6 +1124,7 @@ class LearningAnalyticsService
                     ->leftJoin('knowledge_points as kp', 'sm.kp', '=', 'kp.kp_code')
                     ->where('sm.student_id', $studentId)
                     ->where('sm.mastery', '<', 0.7) // 掌握度低于70%视为薄弱点
+                    ->whereNotNull('kp.parent_kp_code') // 排除根节点(如 M00/S000_000)
                     ->orderBy('sm.mastery', 'asc')
                     ->limit($limit)
                     ->select([

+ 428 - 0
app/Support/OptionLayoutDecider.php

@@ -0,0 +1,428 @@
+<?php
+
+namespace App\Support;
+
+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);
+    }
+
+    /**
+     * @param array<int|string, mixed> $options
+     * @return array{class:string,layout:string,max_length:int,max_width_units:float,avg_width_units:float,width_std_dev:float,has_complex_formula:bool,opt_count:int}
+     */
+    public function decide(array $options, string $context = 'exam'): array
+    {
+        $optCount = count($options);
+        $maxOptionLength = 0;
+        $maxWidthUnits = 0.0;
+        $widthUnitsList = [];
+        $sumWidthUnits = 0.0;
+        $compactMathCount = 0;
+        $plainCompactCount = 0;
+        $optionMetas = [];
+        $hasComplexFormulaOption = false;
+
+        foreach ($options as $option) {
+            $optionMeta = $this->analyzeOption((string) $option);
+            $maxOptionLength = max($maxOptionLength, $optionMeta['effective_length']);
+            $maxWidthUnits = max($maxWidthUnits, $optionMeta['width_units']);
+            $widthUnitsList[] = $optionMeta['width_units'];
+            $sumWidthUnits += $optionMeta['width_units'];
+            if ($optionMeta['is_compact_math']) {
+                $compactMathCount++;
+            }
+            if ($optionMeta['is_plain_compact']) {
+                $plainCompactCount++;
+            }
+            $optionMetas[] = $optionMeta;
+            $hasComplexFormulaOption = $hasComplexFormulaOption || $optionMeta['is_complex_formula'];
+        }
+
+        [$grid4Threshold, $grid2Threshold, $grid4WidthCap, $grid2WidthCap, $grid1WidthCap] = $this->thresholdsFor($context);
+        $avgWidthUnits = $optCount > 0 ? ($sumWidthUnits / $optCount) : 0.0;
+        $widthVariance = $this->variance($widthUnitsList, $avgWidthUnits);
+        $widthStdDev = sqrt($widthVariance);
+        $maxAvgRatio = $avgWidthUnits > 0 ? ($maxWidthUnits / $avgWidthUnits) : 0.0;
+
+        // 4列仅在“整体紧凑 + 选项宽度分布均匀”时启用,避免单个长选项把 D 挤换行
+        $allowGrid4ByWidth = $maxWidthUnits <= ($grid4WidthCap * 0.92)
+            && $maxAvgRatio <= 1.42
+            && $widthStdDev <= 1.65;
+        $allCompactMath = $optCount > 0 && $compactMathCount === $optCount;
+        $allowGrid4ByCompactMath = $optCount === 4
+            && $allCompactMath
+            && $maxWidthUnits <= ($grid4WidthCap * 1.04)
+            && $widthStdDev <= 2.1;
+        $allowGrid4ByPlainCompact = $optCount === 4
+            && $plainCompactCount === $optCount
+            && $maxOptionLength <= 9
+            && $maxWidthUnits <= ($grid4WidthCap * 1.20)
+            && $widthStdDev <= 2.4;
+        $forceGrid4ByPlainShort = $optCount === 4
+            && $plainCompactCount === $optCount
+            && $maxOptionLength <= 8
+            && ! $hasComplexFormulaOption;
+
+        $canTryGrid4 = $optCount <= 4
+            && ! $hasComplexFormulaOption
+            && (
+                (
+                    $maxOptionLength <= $grid4Threshold
+                    && $allowGrid4ByWidth
+                )
+                || $allowGrid4ByCompactMath
+                || $allowGrid4ByPlainCompact
+                || $forceGrid4ByPlainShort
+            );
+        $canTryGrid2 = $maxOptionLength <= $grid2Threshold && $maxWidthUnits <= ($grid2WidthCap * 1.18);
+
+        $layoutCandidates = [];
+        if ($canTryGrid4) {
+            $layoutCandidates['options-grid-4'] = $this->layoutScore($optionMetas, 4, $grid4WidthCap);
+        }
+        if ($canTryGrid2) {
+            $layoutCandidates['options-grid-2'] = $this->layoutScore($optionMetas, 2, $grid2WidthCap);
+        }
+        $layoutCandidates['options-grid-1'] = $this->layoutScore($optionMetas, 1, $grid1WidthCap);
+
+        // 如果2列可行,尽量避免退化到1列(除非1列显著更优)
+        if (isset($layoutCandidates['options-grid-2'], $layoutCandidates['options-grid-1'])) {
+            $s2 = $layoutCandidates['options-grid-2']['score'];
+            $s1 = $layoutCandidates['options-grid-1']['score'];
+            if ($s2 <= ($s1 + 1.6)) {
+                $layoutCandidates['options-grid-2']['score'] -= 0.35;
+            }
+        }
+
+        $selectedClass = 'options-grid-1';
+        $selectedScore = PHP_FLOAT_MAX;
+        foreach ($layoutCandidates as $class => $meta) {
+            if ($meta['score'] < $selectedScore) {
+                $selectedClass = $class;
+                $selectedScore = $meta['score'];
+            }
+        }
+
+        if ($selectedClass === 'options-grid-4') {
+            return [
+                'class' => 'options-grid-4',
+                'layout' => '4列布局',
+                'max_length' => $maxOptionLength,
+                'max_width_units' => $maxWidthUnits,
+                'avg_width_units' => $avgWidthUnits,
+                'width_std_dev' => $widthStdDev,
+                'has_complex_formula' => $hasComplexFormulaOption,
+                'opt_count' => $optCount,
+            ];
+        }
+
+        if ($selectedClass === 'options-grid-2') {
+            return [
+                'class' => 'options-grid-2',
+                'layout' => '2列布局',
+                'max_length' => $maxOptionLength,
+                'max_width_units' => $maxWidthUnits,
+                'avg_width_units' => $avgWidthUnits,
+                'width_std_dev' => $widthStdDev,
+                'has_complex_formula' => $hasComplexFormulaOption,
+                'opt_count' => $optCount,
+            ];
+        }
+
+        return [
+            'class' => 'options-grid-1',
+            'layout' => '1列布局',
+            'max_length' => $maxOptionLength,
+            'max_width_units' => $maxWidthUnits,
+            'avg_width_units' => $avgWidthUnits,
+            'width_std_dev' => $widthStdDev,
+            'has_complex_formula' => $hasComplexFormulaOption,
+            'opt_count' => $optCount,
+        ];
+    }
+
+    /**
+     * @return array{effective_length:int,raw_length:int,width_units:float,height_units:float,is_complex_formula:bool,is_compact_math:bool,is_plain_compact:bool,has_stacked_fraction:bool}
+     */
+    private function analyzeOption(string $option): array
+    {
+        $optionText = html_entity_decode(strip_tags($option), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+        $optionText = preg_replace('/\s+/u', '', $optionText) ?? '';
+        $optionTextNoDollar = preg_replace('/^\$(.*)\$$/u', '$1', $optionText) ?? $optionText;
+        $rawLength = mb_strlen($optionText, 'UTF-8');
+        $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',
+            $optionTextNoDollar
+        ) === 1;
+        $isCompactLatexDegree = preg_match(
+            '/^-?[0-9]+(?:\.[0-9]+)?(?:\^\{?\\\\circ\}?|°)$/u',
+            $optionTextNoDollar
+        ) === 1;
+        $isCompactMath = $isSimpleCompactMath || $isCompactLatexFraction || $isCompactLatexDegree;
+        $isSimpleSymbolLatex = preg_match('/^\\\\(pm|mp)\s*[0-9]+$/u', $optionTextNoDollar) === 1;
+        $isPlainCompact = (
+            preg_match('/\\\\[a-zA-Z]+/u', $optionTextNoDollar) !== 1
+            || $isSimpleSymbolLatex
+        ) && mb_strlen($optionTextNoDollar, 'UTF-8') <= 10;
+
+        $hasLatexCmd = preg_match('/\\\\(frac|dfrac|sqrt|log|sin|cos|tan|cdot|times|left|right|begin|end)/u', $optionText) === 1;
+        $hasStackedFraction = preg_match('/\\\\d?frac\{[^{}]+\}\{[^{}]+\}/u', $optionTextNoDollar) === 1;
+        // 对可安全转为行内分式的短表达,不计入“堆叠分式”高度惩罚
+        if ($isCompactLatexFraction) {
+            $hasStackedFraction = false;
+        }
+        $sqrtCount = preg_match_all('/\\\\sqrt|√/u', $optionText);
+        $supCount = preg_match_all('/\^/u', $optionText);
+        $operatorCount = preg_match_all('/[=<>+\-*\/\^_]/u', $optionText);
+        $hasBrackets = preg_match('/[()\(\)\[\]\{\}]/u', $optionText) === 1;
+        $isComplexFormula = ! $isCompactMath
+            && ($hasLatexCmd || $operatorCount >= 2 || ($hasBrackets && $optionLength >= 8));
+
+        if ($isComplexFormula) {
+            $optionLength += 6;
+        }
+
+        return [
+            'effective_length' => $optionLength,
+            'raw_length' => $rawLength,
+            'width_units' => $this->estimateWidthUnits($optionText),
+            'height_units' => 1.0
+                + ($hasStackedFraction ? 0.72 : 0.0)
+                + min(0.36, ((int) $sqrtCount) * 0.08)
+                + min(0.28, ((int) $supCount) * 0.07),
+            'is_complex_formula' => $isComplexFormula,
+            'is_compact_math' => $isCompactMath,
+            'is_plain_compact' => $isPlainCompact,
+            'has_stacked_fraction' => $hasStackedFraction,
+        ];
+    }
+
+    /**
+     * 判卷页面中题干+答案区域更紧凑,阈值应更保守。
+     *
+     * @return array{0:int,1:int,2:float,3:float,4:float}
+     */
+    private function thresholdsFor(string $context): array
+    {
+        if ($context === 'grading') {
+            return [10, 24, 10.2, 21.0, 44.0];
+        }
+
+        return [12, 28, 11.5, 23.5, 48.0];
+    }
+
+    private function estimateWidthUnits(string $text): float
+    {
+        if ($text === '') {
+            return 0.0;
+        }
+
+        // 简单TeX归一:减少命令名对宽度估计的干扰
+        $normalized = preg_replace('/\\\\(left|right|displaystyle)/u', '', $text) ?? $text;
+        $normalized = preg_replace('/\\\\(frac|dfrac)\{([^{}]+)\}\{([^{}]+)\}/u', '($2/$3)', $normalized) ?? $normalized;
+        $normalized = preg_replace('/\\\\sqrt\{([^{}]+)\}/u', '√($1)', $normalized) ?? $normalized;
+
+        $chars = preg_split('//u', $normalized, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+        $units = 0.0;
+        foreach ($chars as $ch) {
+            if (preg_match('/[\x{4e00}-\x{9fff}]/u', $ch)) {
+                $units += 1.0;
+            } elseif (preg_match('/[A-Za-z]/u', $ch)) {
+                $units += 0.62;
+            } elseif (preg_match('/[0-9]/u', $ch)) {
+                $units += 0.58;
+            } elseif (preg_match('/[=<>+\-*\/\^_]/u', $ch)) {
+                $units += 0.45;
+            } elseif (preg_match('/[()\(\)\[\]\{\}]/u', $ch)) {
+                $units += 0.35;
+            } elseif ($ch === '√') {
+                $units += 0.55;
+            } else {
+                $units += 0.5;
+            }
+        }
+
+        // 选项标签(A.)和左侧间距补偿
+        return $units + 2.2;
+    }
+
+    /**
+     * @param array<int,array{width_units:float,height_units:float,has_stacked_fraction:bool}> $optionMetas
+     * @return array{score:float}
+     */
+    private function layoutScore(array $optionMetas, int $cols, float $colCap): array
+    {
+        $count = count($optionMetas);
+        if ($count === 0) {
+            return ['score' => 0.0];
+        }
+
+        $overflowCount = 0;
+        $lineWrapPenalty = 0.0;
+        $rowHeights = [];
+        $stackedFractionCount = 0;
+
+        foreach ($optionMetas as $idx => $meta) {
+            $effectiveWidth = $meta['width_units'] + ($meta['has_stacked_fraction'] ? ($cols === 4 ? 1.6 : 0.6) : 0.0);
+            if ($effectiveWidth > $colCap) {
+                $overflowCount++;
+            }
+            $lines = max(1.0, ceil($effectiveWidth / max(1.0, $colCap)));
+            $lineWrapPenalty += max(0.0, $lines - 1.0);
+            $itemHeight = $meta['height_units'] * $lines;
+
+            $rowIndex = intdiv($idx, $cols);
+            if (! isset($rowHeights[$rowIndex])) {
+                $rowHeights[$rowIndex] = 0.0;
+            }
+            $rowHeights[$rowIndex] = max($rowHeights[$rowIndex], $itemHeight);
+
+            if ($meta['has_stacked_fraction']) {
+                $stackedFractionCount++;
+            }
+        }
+
+        $overflowRate = $overflowCount / $count;
+        $lineWrapRate = $lineWrapPenalty / $count;
+        $rowAvg = array_sum($rowHeights) / max(1, count($rowHeights));
+        $rowStd = sqrt($this->variance(array_values($rowHeights), $rowAvg));
+        $rowCv = $rowAvg > 0 ? ($rowStd / $rowAvg) : 0.0;
+        $stackedRate = $stackedFractionCount / $count;
+
+        $whitespacePenalty = match ($cols) {
+            1 => 2.4,
+            2 => 0.7,
+            default => 0.0,
+        };
+
+        $score = ($overflowRate * 120.0)
+            + ($lineWrapRate * 10.0)
+            + ($rowCv * 5.0)
+            + ($stackedRate * ($cols === 4 ? 2.6 : 0.8))
+            + $whitespacePenalty;
+
+        return ['score' => $score];
+    }
+
+    /**
+     * @param array<int,float> $values
+     */
+    private function variance(array $values, float $avg): float
+    {
+        if (empty($values)) {
+            return 0.0;
+        }
+
+        $sum = 0.0;
+        foreach ($values as $v) {
+            $d = $v - $avg;
+            $sum += ($d * $d);
+        }
+
+        return $sum / count($values);
+    }
+
+    /**
+     * @return array{0:string,1:string}|null
+     */
+    private function extractSingleFractionParts(string $text): ?array
+    {
+        if (! preg_match('/^\\\\d?frac/u', $text)) {
+            return null;
+        }
+        $offset = preg_match('/^\\\\dfrac/u', $text) ? 6 : 5; // \dfrac or \frac
+        $len = mb_strlen($text, 'UTF-8');
+
+        if ($offset >= $len || mb_substr($text, $offset, 1, 'UTF-8') !== '{') {
+            return null;
+        }
+
+        [$num, $next] = $this->readBalancedBraces($text, $offset);
+        if ($num === null || $next >= $len || mb_substr($text, $next, 1, 'UTF-8') !== '{') {
+            return null;
+        }
+
+        [$den, $end] = $this->readBalancedBraces($text, $next);
+        if ($den === null) {
+            return null;
+        }
+
+        // 必须刚好到结尾,避免把 "\frac{a}{b}\text{cm}" 这类误改坏
+        if ($end !== $len) {
+            return null;
+        }
+
+        return [$num, $den];
+    }
+
+    private function hasBinaryOperator(string $expr): bool
+    {
+        return preg_match('/(?<!^)[+\-*]/u', $expr) === 1;
+    }
+
+    /**
+     * @return array{0:string|null,1:int}
+     */
+    private function readBalancedBraces(string $text, int $startOffset): array
+    {
+        $len = mb_strlen($text, 'UTF-8');
+        if ($startOffset >= $len || mb_substr($text, $startOffset, 1, 'UTF-8') !== '{') {
+            return [null, $startOffset];
+        }
+
+        $depth = 0;
+        $buffer = '';
+        for ($i = $startOffset; $i < $len; $i++) {
+            $ch = mb_substr($text, $i, 1, 'UTF-8');
+            if ($ch === '{') {
+                $depth++;
+                if ($depth > 1) {
+                    $buffer .= $ch;
+                }
+                continue;
+            }
+            if ($ch === '}') {
+                $depth--;
+                if ($depth === 0) {
+                    return [$buffer, $i + 1];
+                }
+                if ($depth < 0) {
+                    return [null, $i + 1];
+                }
+                $buffer .= $ch;
+                continue;
+            }
+            $buffer .= $ch;
+        }
+
+        return [null, $len];
+    }
+}

+ 1 - 0
bootstrap/app.php

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

+ 28 - 30
resources/views/components/exam/paper-body.blade.php

@@ -68,6 +68,7 @@
     }
 
     $boxCounter = app(\App\Support\GradingMarkBoxCounter::class);
+    $layoutDeciderService = app(\App\Support\OptionLayoutDecider::class);
     // 与判题卡共用同一计数规则,避免方框数量不一致
     $countBlanks = fn($text) => $boxCounter->countFillBlanks($text);
 
@@ -202,36 +203,19 @@
                 @endif
                 @if((!$gradingMode || $showGradingStem) && !empty($options))
                     @php
-                        // 计算选项长度并动态选择布局
-                        $optCount = count($options);
-                        $maxOptionLength = 0;
-                        foreach ($options as $opt) {
-                            // 用原始选项文本估算长度,避免公式渲染后的冗余DOM文本干扰列数判断
-                            $optText = strip_tags((string) $opt);
-                            $optText = preg_replace('/\\\\[a-zA-Z]+|[\\{\\}\\$\\^_]/u', '', $optText);
-                            $optText = preg_replace('/\s+/u', '', (string) $optText);
-                            $maxOptionLength = max($maxOptionLength, mb_strlen($optText, 'UTF-8'));
-                        }
-
-                        // 根据最长选项长度和选项数量动态选择布局
-                        // 短选项(≤15字符)且选项数≤4:4列布局
-                        // 中等选项(16-30字符)或选项数>4:2列布局
-                        // 长选项(>30字符):1列布局
-                        if ($maxOptionLength <= 13) {
-                            $optionsClass = 'options-grid-4';
-                            $layoutDesc = '4列布局';
-                        } elseif ($maxOptionLength <= 26) {
-                            $optionsClass = 'options-grid-2';
-                            $layoutDesc = '2列布局';
-                        } else {
-                            $optionsClass = 'options-grid-1';
-                            $layoutDesc = '1列布局';
-                        }
+                        $layoutMeta = $layoutDeciderService->decide(
+                            $options,
+                            $gradingMode ? 'grading' : 'exam'
+                        );
+                        $optionsClass = $layoutMeta['class'];
+                        $layoutDesc = $layoutMeta['layout'];
 
                         \Illuminate\Support\Facades\Log::debug('选择题布局决策', [
                             'question_number' => $questionNumber,
-                            'opt_count' => $optCount,
-                            'max_length' => $maxOptionLength,
+                            'context' => $gradingMode ? 'grading' : 'exam',
+                            'opt_count' => $layoutMeta['opt_count'],
+                            'max_length' => $layoutMeta['max_length'],
+                            'has_complex_formula' => $layoutMeta['has_complex_formula'],
                             'selected_class' => $optionsClass,
                             'layout' => $layoutDesc
                         ]);
@@ -251,6 +235,7 @@
                                 // 选项内优先使用行内分式,避免 \dfrac 导致单个选项视觉突兀
                                 $normalizedOpt = str_replace('\\dfrac', '\\frac', $normalizedOpt);
                                 $normalizedOpt = str_replace('\\displaystyle', '', $normalizedOpt);
+                                $normalizedOpt = $layoutDeciderService->normalizeCompactMathForDisplay($normalizedOpt);
                                 // 清理来源HTML里可能携带的超大字号,避免单题选项异常放大
                                 $normalizedOpt = preg_replace('/font-size\s*:[^;"]+;?/iu', '', $normalizedOpt);
                                 $normalizedOpt = preg_replace('/line-height\s*:[^;"]+;?/iu', '', $normalizedOpt);
@@ -264,9 +249,16 @@
                                     $encodedOpt = htmlspecialchars($normalizedOpt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                                     $renderedOpt = \App\Services\MathFormulaProcessor::processFormulas($encodedOpt);
                                 }
+
+                                // 细粒度控制:短选项(如 1/2、-1/3、x、-x)尽量单行展示,长选项允许换行
+                                $rawOptText = html_entity_decode(strip_tags((string) $opt), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                                $rawOptText = preg_replace('/\s+/u', '', $rawOptText ?? '');
+                                $rawOptLen = mb_strlen((string) $rawOptText, 'UTF-8');
+                                $isShortOption = $rawOptLen <= 8;
                             @endphp
                             <div class="option option-compact">
-                                <strong>{{ $label }}.</strong>&nbsp;{!! $renderedOpt !!}
+                                <strong>{{ $label }}.</strong>
+                                <span class="option-value {{ $isShortOption ? 'option-short' : 'option-long' }}">{!! $renderedOpt !!}</span>
                             </div>
                         @endforeach
                     </div>
@@ -284,7 +276,10 @@
                         <div class="question-lead spacer"></div>
                     @endif
                     <div class="answer-meta">
-                        <div class="answer-line"><strong>正确答案:</strong><span class="solution-content">{!! $mathProcessed ? ($q->answer ?? '') : \App\Services\MathFormulaProcessor::processFormulas($q->answer ?? '') !!}</span></div>
+                        @php
+                            $choiceAnswerRaw = $layoutDeciderService->normalizeCompactMathForDisplay((string) ($q->answer ?? ''));
+                        @endphp
+                        <div class="answer-line"><strong>正确答案:</strong><span class="solution-content">{!! $mathProcessed ? $choiceAnswerRaw : \App\Services\MathFormulaProcessor::processFormulas($choiceAnswerRaw) !!}</span></div>
                         <div class="answer-line"><strong>解题思路:</strong><span class="solution-content">{!! $solutionHtml !!}</span></div>
                     </div>
                 @endif
@@ -379,7 +374,10 @@
                         <div class="question-lead spacer"></div>
                     @endif
                     <div class="answer-meta">
-                        <div class="answer-line"><strong>正确答案:</strong><span class="solution-content">{!! $mathProcessed ? ($q->answer ?? '') : \App\Services\MathFormulaProcessor::processFormulas($q->answer ?? '') !!}</span></div>
+                        @php
+                            $fillAnswerRaw = $layoutDeciderService->normalizeCompactMathForDisplay((string) ($q->answer ?? ''));
+                        @endphp
+                        <div class="answer-line"><strong>正确答案:</strong><span class="solution-content">{!! $mathProcessed ? $fillAnswerRaw : \App\Services\MathFormulaProcessor::processFormulas($fillAnswerRaw) !!}</span></div>
                         <div class="answer-line"><strong>解题思路:</strong><span class="solution-content">{!! $solutionHtml !!}</span></div>
                     </div>
                 @endif

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

@@ -160,6 +160,9 @@
             break-inside: avoid;
         }
         .option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+        .option-value { display: inline; }
+        .option-short { white-space: nowrap; }
+        .option-long { white-space: normal; word-break: break-word; }
         .option-compact { line-height: inherit; }
         .option p, .option div { margin: 0; display: inline; }
         .option .katex {

+ 3 - 0
resources/views/pdf/exam-paper.blade.php

@@ -206,6 +206,9 @@
             break-inside: avoid;
         }
         .option strong { margin-right: 4px; flex: 0 0 auto; line-height: 1.6; }
+        .option-value { display: inline; }
+        .option-short { white-space: nowrap; }
+        .option-long { white-space: normal; word-break: break-word; }
         .option-inline { display: inline-flex; align-items: baseline; margin-right: 20px; }
         .option-compact { line-height: inherit; }
         .option p, .option div { margin: 0; display: inline; }

+ 98 - 0
tests/Unit/OptionLayoutDeciderTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Support\OptionLayoutDecider;
+use PHPUnit\Framework\TestCase;
+
+class OptionLayoutDeciderTest extends TestCase
+{
+    public function test_short_plain_options_use_four_columns_in_exam(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(['-3', '3', 'x', '-x'], 'exam');
+
+        $this->assertSame('options-grid-4', $result['class']);
+    }
+
+    public function test_complex_formula_options_do_not_use_four_columns(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(
+            ['若a^x=b,则x=log_a b', '若x^a=b,则x=log_a b', '若a=b^x,则x=log_a b', '若a^b=x,则x=log_a b'],
+            'exam'
+        );
+
+        $this->assertNotSame('options-grid-4', $result['class']);
+    }
+
+    public function test_grading_context_is_more_conservative_than_exam(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $options = ['x+1', 'x-1', 'x^2', 'x^3'];
+
+        $exam = $decider->decide($options, 'exam');
+        $grading = $decider->decide($options, 'grading');
+
+        $rank = [
+            'options-grid-1' => 1,
+            'options-grid-2' => 2,
+            'options-grid-4' => 3,
+        ];
+
+        $this->assertLessThanOrEqual($rank[$exam['class']], $rank[$grading['class']]);
+    }
+
+    public function test_short_fraction_like_choices_prefer_four_columns(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(['1/2', '-1/2', '√3/2', '-√3/2'], 'exam');
+
+        $this->assertSame('options-grid-4', $result['class']);
+    }
+
+    public function test_compact_latex_fraction_choices_prefer_four_columns(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(['5', '9', '\\frac{5}{2}', '\\frac{9}{2}'], 'exam');
+
+        $this->assertSame('options-grid-4', $result['class']);
+    }
+
+    public function test_compact_degree_choices_prefer_four_columns(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(['15^\\circ', '20^\\circ', '25^\\circ', '30^\\circ'], 'exam');
+
+        $this->assertSame('options-grid-4', $result['class']);
+    }
+
+    public function test_long_chinese_text_choices_should_not_be_forced_into_four_columns(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(
+            ['充分不必要条件', '必要不充分条件', '充要条件', '既不充分又不必要条件'],
+            'exam'
+        );
+
+        $this->assertNotSame('options-grid-4', $result['class']);
+    }
+
+    public function test_mixed_root_and_fraction_choices_prefer_two_columns_over_one(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $result = $decider->decide(
+            ['\\sqrt{3}', '\\sqrt{2}', '\\frac{\\sqrt{3}}{3}', '\\frac{\\sqrt{2}}{2}'],
+            'exam'
+        );
+
+        $this->assertSame('options-grid-2', $result['class']);
+    }
+
+    public function test_normalize_compact_fraction_for_display(): void
+    {
+        $decider = new OptionLayoutDecider();
+        $this->assertSame('\\sqrt{3}/2', $decider->normalizeCompactMathForDisplay('\\frac{\\sqrt{3}}{2}'));
+        $this->assertSame('\\frac{x+1}{2}', $decider->normalizeCompactMathForDisplay('\\frac{x+1}{2}'));
+    }
+}