|
@@ -7,6 +7,7 @@ use App\DTO\ReportPayloadDto;
|
|
|
use App\Models\Paper;
|
|
use App\Models\Paper;
|
|
|
use App\Models\Question;
|
|
use App\Models\Question;
|
|
|
use App\Models\Student;
|
|
use App\Models\Student;
|
|
|
|
|
+use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
|
|
|
use App\Support\GradingStyleQuestionStem;
|
|
use App\Support\GradingStyleQuestionStem;
|
|
|
use App\Support\PaperNaming;
|
|
use App\Support\PaperNaming;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\DB;
|
|
@@ -269,6 +270,15 @@ class ExamPdfExportService
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
|
|
+ $flowStart = microtime(true);
|
|
|
|
|
+ $lastMark = $flowStart;
|
|
|
|
|
+ $marks = [];
|
|
|
|
|
+ $mark = static function (string $label) use (&$lastMark, &$marks): void {
|
|
|
|
|
+ $now = microtime(true);
|
|
|
|
|
+ $marks[$label] = round(($now - $lastMark) * 1000, 1);
|
|
|
|
|
+ $lastMark = $now;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
|
|
Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
|
|
|
'paper_id' => $paperId,
|
|
'paper_id' => $paperId,
|
|
|
'student_id' => $studentId,
|
|
'student_id' => $studentId,
|
|
@@ -277,6 +287,7 @@ class ExamPdfExportService
|
|
|
|
|
|
|
|
// 构建分析数据
|
|
// 构建分析数据
|
|
|
$analysisData = $this->buildAnalysisData($paperId, $studentId);
|
|
$analysisData = $this->buildAnalysisData($paperId, $studentId);
|
|
|
|
|
+ $mark('build_analysis_data_ms');
|
|
|
if (! $analysisData) {
|
|
if (! $analysisData) {
|
|
|
Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
|
|
Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
|
|
|
'paper_id' => $paperId,
|
|
'paper_id' => $paperId,
|
|
@@ -297,6 +308,7 @@ class ExamPdfExportService
|
|
|
// 创建DTO
|
|
// 创建DTO
|
|
|
$dto = ExamAnalysisDataDto::fromArray($analysisData);
|
|
$dto = ExamAnalysisDataDto::fromArray($analysisData);
|
|
|
$payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
|
|
$payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
|
|
|
|
|
+ $mark('build_payload_dto_ms');
|
|
|
|
|
|
|
|
// 打印传给模板的数据
|
|
// 打印传给模板的数据
|
|
|
$templateData = $payloadDto->toArray();
|
|
$templateData = $payloadDto->toArray();
|
|
@@ -322,12 +334,15 @@ class ExamPdfExportService
|
|
|
$templateData['question_insights'][$idx] = MathFormulaProcessor::processQuestionData($insight);
|
|
$templateData['question_insights'][$idx] = MathFormulaProcessor::processQuestionData($insight);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ $mark('process_formula_data_ms');
|
|
|
|
|
|
|
|
// 组装V3报告展示数据(模块化)
|
|
// 组装V3报告展示数据(模块化)
|
|
|
$templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
|
|
$templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
|
|
|
|
|
+ $mark('build_v3_data_ms');
|
|
|
|
|
|
|
|
// 渲染HTML(V3模板)
|
|
// 渲染HTML(V3模板)
|
|
|
$html = view('exam-analysis.pdf-report-v3', $templateData)->render();
|
|
$html = view('exam-analysis.pdf-report-v3', $templateData)->render();
|
|
|
|
|
+ $mark('render_html_ms');
|
|
|
if (! $html) {
|
|
if (! $html) {
|
|
|
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
|
|
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
|
|
|
|
|
|
|
@@ -336,6 +351,7 @@ class ExamPdfExportService
|
|
|
|
|
|
|
|
// 生成PDF
|
|
// 生成PDF
|
|
|
$pdfBinary = $this->buildPdf($html);
|
|
$pdfBinary = $this->buildPdf($html);
|
|
|
|
|
+ $mark('build_pdf_ms');
|
|
|
if (! $pdfBinary) {
|
|
if (! $pdfBinary) {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
@@ -349,6 +365,7 @@ class ExamPdfExportService
|
|
|
$safeAnalysisFile = PaperNaming::toSafeFilename($analysisBase) . '.pdf';
|
|
$safeAnalysisFile = PaperNaming::toSafeFilename($analysisBase) . '.pdf';
|
|
|
$path = "analysis_reports/{$safeAnalysisFile}";
|
|
$path = "analysis_reports/{$safeAnalysisFile}";
|
|
|
$url = $this->pdfStorageService->put($path, $pdfBinary);
|
|
$url = $this->pdfStorageService->put($path, $pdfBinary);
|
|
|
|
|
+ $mark('upload_pdf_ms');
|
|
|
if (! $url) {
|
|
if (! $url) {
|
|
|
Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
|
|
Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
|
|
|
|
|
|
|
@@ -357,6 +374,15 @@ class ExamPdfExportService
|
|
|
|
|
|
|
|
// 保存URL到数据库
|
|
// 保存URL到数据库
|
|
|
$this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
|
|
$this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
|
|
|
|
|
+ $mark('save_pdf_url_ms');
|
|
|
|
|
+ $marks['total_ms'] = round((microtime(true) - $flowStart) * 1000, 1);
|
|
|
|
|
+
|
|
|
|
|
+ Log::info('ExamPdfExportService: 学情分析PDF耗时明细', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'record_id' => $recordId,
|
|
|
|
|
+ 'timing' => $marks,
|
|
|
|
|
+ ]);
|
|
|
|
|
|
|
|
return $url;
|
|
return $url;
|
|
|
|
|
|
|
@@ -543,8 +569,22 @@ class ExamPdfExportService
|
|
|
? (float) $overallSummary['average_mastery']
|
|
? (float) $overallSummary['average_mastery']
|
|
|
: $this->averageByKey(array_filter($dimensions, fn ($d) => $d['mastery_level'] !== null), 'mastery_level');
|
|
: $this->averageByKey(array_filter($dimensions, fn ($d) => $d['mastery_level'] !== null), 'mastery_level');
|
|
|
|
|
|
|
|
- $overallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery);
|
|
|
|
|
$paths = $this->buildV3LearningPaths($dimensions);
|
|
$paths = $this->buildV3LearningPaths($dimensions);
|
|
|
|
|
+ $difficultyInsight = $this->buildV3DifficultyInsight($templateData);
|
|
|
|
|
+ $comparisonInsight = $this->buildV3ComparisonInsight(
|
|
|
|
|
+ $templateData,
|
|
|
|
|
+ $scoreRate,
|
|
|
|
|
+ $avgMastery
|
|
|
|
|
+ );
|
|
|
|
|
+ $fallbackOverallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery);
|
|
|
|
|
+ $overallComposite = $this->buildV3OverallCompositeEvaluation(
|
|
|
|
|
+ $scoreRate,
|
|
|
|
|
+ $avgMastery,
|
|
|
|
|
+ $difficultyInsight,
|
|
|
|
|
+ $comparisonInsight,
|
|
|
|
|
+ $fallbackOverallLabel
|
|
|
|
|
+ );
|
|
|
|
|
+ $overallLabel = (string) ($overallComposite['label'] ?? $fallbackOverallLabel);
|
|
|
|
|
|
|
|
// 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点)
|
|
// 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点)
|
|
|
// 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。
|
|
// 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。
|
|
@@ -634,7 +674,10 @@ class ExamPdfExportService
|
|
|
'score_rate' => $scoreRate,
|
|
'score_rate' => $scoreRate,
|
|
|
'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
|
|
'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
|
|
|
'overall_label' => $overallLabel,
|
|
'overall_label' => $overallLabel,
|
|
|
|
|
+ 'overall_label_detail' => $overallComposite,
|
|
|
'stage' => $stage,
|
|
'stage' => $stage,
|
|
|
|
|
+ 'difficulty' => $difficultyInsight,
|
|
|
|
|
+ 'comparison' => $comparisonInsight,
|
|
|
],
|
|
],
|
|
|
'radar' => $radar,
|
|
'radar' => $radar,
|
|
|
'modules' => $moduleRows,
|
|
'modules' => $moduleRows,
|
|
@@ -739,6 +782,527 @@ class ExamPdfExportService
|
|
|
return $count > 0 ? ($sum / $count) : null;
|
|
return $count > 0 ? ($sum / $count) : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private function buildV3DifficultyInsight(array $templateData): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $paper = $templateData['paper'] ?? [];
|
|
|
|
|
+ $questions = $templateData['questions'] ?? [];
|
|
|
|
|
+ $rawCategory = (string) ($paper['difficulty_category'] ?? '');
|
|
|
|
|
+ $level = QuestionDifficultyCalibrationAnalyzer::parsePaperDifficultyCategory($rawCategory);
|
|
|
|
|
+ $levelInt = $level !== null ? (int) round($level) : null;
|
|
|
|
|
+ $range = $this->difficultyRangeByLevel($levelInt);
|
|
|
|
|
+
|
|
|
|
|
+ $difficulties = [];
|
|
|
|
|
+ foreach ($questions as $q) {
|
|
|
|
|
+ $d = $q['difficulty'] ?? null;
|
|
|
|
|
+ if ($d !== null && is_numeric($d)) {
|
|
|
|
|
+ $dv = (float) $d;
|
|
|
|
|
+ if ($dv >= 0.0 && $dv <= 1.0) {
|
|
|
|
|
+ $difficulties[] = $dv;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $actualAvg = ! empty($difficulties) ? (array_sum($difficulties) / count($difficulties)) : null;
|
|
|
|
|
+ $actualLevel = $actualAvg !== null ? $this->mapDifficultyValueToLevel($actualAvg) : null;
|
|
|
|
|
+ $targetCenter = $range !== null ? (($range['min'] + $range['max']) / 2.0) : null;
|
|
|
|
|
+ $deviation = ($actualAvg !== null && $targetCenter !== null) ? ($actualAvg - $targetCenter) : null;
|
|
|
|
|
+
|
|
|
|
|
+ $matchStatus = '暂无';
|
|
|
|
|
+ if ($actualAvg !== null && $range !== null) {
|
|
|
|
|
+ if ($actualAvg > $range['max']) {
|
|
|
|
|
+ $matchStatus = '偏难';
|
|
|
|
|
+ } elseif ($actualAvg < $range['min']) {
|
|
|
|
|
+ $matchStatus = '偏易';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $matchStatus = '匹配';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $explain = '暂无足够数据评估难度匹配。';
|
|
|
|
|
+ if ($matchStatus === '匹配') {
|
|
|
|
|
+ $explain = '本次题目整体难度与学案目标难度基本一致,结果可直接反映当前掌握水平。';
|
|
|
|
|
+ } elseif ($matchStatus === '偏难') {
|
|
|
|
|
+ $explain = '本次题目整体偏难,错误率偏高有客观因素,建议先补齐同模块中档题再冲高档。';
|
|
|
|
|
+ } elseif ($matchStatus === '偏易') {
|
|
|
|
|
+ $explain = '本次题目整体偏易,若得分高不代表上限已到,建议补充更高一档难度验证稳定性。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'target_category_raw' => $rawCategory,
|
|
|
|
|
+ 'target_level' => $levelInt,
|
|
|
|
|
+ 'target_label' => $this->difficultyLevelLabel($levelInt, $rawCategory),
|
|
|
|
|
+ 'target_range' => $range,
|
|
|
|
|
+ 'actual_average_difficulty' => $actualAvg !== null ? round($actualAvg, 4) : null,
|
|
|
|
|
+ 'actual_level' => $actualLevel,
|
|
|
|
|
+ 'actual_label' => $actualLevel !== null ? $this->difficultyLevelLabel($actualLevel) : null,
|
|
|
|
|
+ 'deviation' => $deviation !== null ? round($deviation, 4) : null,
|
|
|
|
|
+ 'status' => $matchStatus,
|
|
|
|
|
+ 'question_count' => count($difficulties),
|
|
|
|
|
+ 'explain' => $explain,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function buildV3ComparisonInsight(array $templateData, ?float $currentScoreRate, ?float $currentMastery): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $paper = $templateData['paper'] ?? [];
|
|
|
|
|
+ $student = $templateData['student'] ?? [];
|
|
|
|
|
+ $studentId = (string) ($student['id'] ?? '');
|
|
|
|
|
+ $grade = (string) ($student['grade'] ?? '');
|
|
|
|
|
+ $paperId = (string) ($paper['id'] ?? '');
|
|
|
|
|
+ $paperType = isset($paper['paper_type']) ? (int) $paper['paper_type'] : null;
|
|
|
|
|
+
|
|
|
|
|
+ $history = [
|
|
|
|
|
+ 'has_data' => false,
|
|
|
|
|
+ 'is_first_exam' => false,
|
|
|
|
|
+ 'low_baseline_guard' => false,
|
|
|
|
|
+ 'sample_size' => 0,
|
|
|
|
|
+ 'baseline_score_rate' => null,
|
|
|
|
|
+ 'delta_score_rate' => null,
|
|
|
|
|
+ 'trend' => '暂无',
|
|
|
|
|
+ 'message' => '历史样本不足,暂无法形成稳定趋势。',
|
|
|
|
|
+ ];
|
|
|
|
|
+ $peers = [
|
|
|
|
|
+ 'has_data' => false,
|
|
|
|
|
+ 'sample_size' => 0,
|
|
|
|
|
+ 'raw_sample_size' => 0,
|
|
|
|
|
+ 'peer_avg_score_rate' => null,
|
|
|
|
|
+ 'delta_score_rate' => null,
|
|
|
|
|
+ 'percentile' => null,
|
|
|
|
|
+ 'band' => '暂无',
|
|
|
|
|
+ 'display_mode' => 'none',
|
|
|
|
|
+ 'show_line' => false,
|
|
|
|
|
+ 'band_icon' => '•',
|
|
|
|
|
+ 'band_color' => '#64748b',
|
|
|
|
|
+ 'message' => '同群体样本不足,暂无法做稳健对比。',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if ($studentId === '') {
|
|
|
|
|
+ return ['history' => $history, 'peers' => $peers];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $historyRows = DB::connection('mysql')
|
|
|
|
|
+ ->table('papers as p')
|
|
|
|
|
+ ->join('paper_questions as pq', 'pq.paper_id', '=', 'p.paper_id')
|
|
|
|
|
+ ->where('p.student_id', $studentId)
|
|
|
|
|
+ ->when($paperId !== '', fn ($q) => $q->where('p.paper_id', '!=', $paperId))
|
|
|
|
|
+ ->groupBy('p.paper_id', 'p.created_at')
|
|
|
|
|
+ ->selectRaw('p.paper_id, p.created_at, SUM(COALESCE(pq.score, 0)) as total_score, SUM(COALESCE(pq.score_obtained, 0)) as obtained_score')
|
|
|
|
|
+ ->havingRaw('SUM(COALESCE(pq.score, 0)) > 0')
|
|
|
|
|
+ ->orderByDesc('p.created_at')
|
|
|
|
|
+ ->limit(7)
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ $historyRates = [];
|
|
|
|
|
+ foreach ($historyRows as $row) {
|
|
|
|
|
+ $total = (float) ($row->total_score ?? 0);
|
|
|
|
|
+ if ($total <= 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $historyRates[] = (float) ($row->obtained_score ?? 0) / $total;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (! empty($historyRates) && $currentScoreRate !== null) {
|
|
|
|
|
+ $baseline = array_sum($historyRates) / count($historyRates);
|
|
|
|
|
+ $delta = $currentScoreRate - $baseline;
|
|
|
|
|
+ $history['has_data'] = true;
|
|
|
|
|
+ $history['sample_size'] = count($historyRates);
|
|
|
|
|
+ $history['baseline_score_rate'] = round($baseline, 4);
|
|
|
|
|
+ $history['delta_score_rate'] = round($delta, 4);
|
|
|
|
|
+ if ($baseline <= 0.02 && count($historyRates) >= 5) {
|
|
|
|
|
+ $history['low_baseline_guard'] = true;
|
|
|
|
|
+ $history['trend'] = '基线偏低';
|
|
|
|
|
+ $history['message'] = '近7次历史基线过低,单次涨跌参考意义有限,建议重点看后续连续3次趋势。';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $history['trend'] = $this->resolveDeltaTrendLabel($delta);
|
|
|
|
|
+ $history['message'] = $delta >= 0
|
|
|
|
|
+ ? '相较于近期个人表现,本次成绩处于上行区间。'
|
|
|
|
|
+ : '相较于近期个人表现,本次成绩略有回落,建议优先复盘错因分布。';
|
|
|
|
|
+ }
|
|
|
|
|
+ } elseif ($currentScoreRate !== null) {
|
|
|
|
|
+ $history['is_first_exam'] = true;
|
|
|
|
|
+ $history['message'] = $this->pickFirstExamEncouragementMessageByScore($currentScoreRate);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: 构建历史对比失败', ['error' => $e->getMessage()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ if ($grade !== '' && $currentScoreRate !== null) {
|
|
|
|
|
+ $peerRows = DB::connection('mysql')
|
|
|
|
|
+ ->table('papers as p')
|
|
|
|
|
+ ->join('students as s', 's.student_id', '=', 'p.student_id')
|
|
|
|
|
+ ->join('paper_questions as pq', 'pq.paper_id', '=', 'p.paper_id')
|
|
|
|
|
+ ->where('s.grade', $grade)
|
|
|
|
|
+ ->where('p.student_id', '!=', $studentId)
|
|
|
|
|
+ ->when($paperType !== null, fn ($q) => $q->where('p.paper_type', $paperType))
|
|
|
|
|
+ ->groupBy('p.paper_id', 'p.created_at')
|
|
|
|
|
+ ->selectRaw('p.paper_id, p.created_at, SUM(COALESCE(pq.score, 0)) as total_score, SUM(COALESCE(pq.score_obtained, 0)) as obtained_score')
|
|
|
|
|
+ ->havingRaw('SUM(COALESCE(pq.score, 0)) > 0')
|
|
|
|
|
+ ->orderByDesc('p.created_at')
|
|
|
|
|
+ ->limit(300)
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ $peerRates = [];
|
|
|
|
|
+ foreach ($peerRows as $row) {
|
|
|
|
|
+ $total = (float) ($row->total_score ?? 0);
|
|
|
|
|
+ if ($total <= 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $peerRates[] = (float) ($row->obtained_score ?? 0) / $total;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (! empty($peerRates)) {
|
|
|
|
|
+ sort($peerRates);
|
|
|
|
|
+ $peerAvg = array_sum($peerRates) / count($peerRates);
|
|
|
|
|
+ $ltCount = 0;
|
|
|
|
|
+ $eqCount = 0;
|
|
|
|
|
+ foreach ($peerRates as $rate) {
|
|
|
|
|
+ if ($rate < $currentScoreRate) {
|
|
|
|
|
+ $ltCount++;
|
|
|
|
|
+ } elseif (abs($rate - $currentScoreRate) < 1e-9) {
|
|
|
|
|
+ $eqCount++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 使用 mid-rank percentile,避免并列值把分位夸大到 100%
|
|
|
|
|
+ $percentile = (100.0 * ($ltCount + 0.5 * $eqCount)) / count($peerRates);
|
|
|
|
|
+ $delta = $currentScoreRate - $peerAvg;
|
|
|
|
|
+ $peerCount = count($peerRates);
|
|
|
|
|
+ $band = $this->resolvePeerBand($percentile);
|
|
|
|
|
+ $bandVisual = $this->resolvePeerBandVisual($band);
|
|
|
|
|
+
|
|
|
|
|
+ $peers['has_data'] = true;
|
|
|
|
|
+ $peers['show_line'] = true;
|
|
|
|
|
+ $peers['sample_size'] = $peerCount;
|
|
|
|
|
+ $peers['raw_sample_size'] = $peerCount;
|
|
|
|
|
+ $peers['peer_avg_score_rate'] = round($peerAvg, 4);
|
|
|
|
|
+ $peers['delta_score_rate'] = round($delta, 4);
|
|
|
|
|
+ $peers['percentile'] = round($percentile, 1);
|
|
|
|
|
+ $peers['band'] = $band;
|
|
|
|
|
+ $peers['band_icon'] = $bandVisual['icon'];
|
|
|
|
|
+ $peers['band_color'] = $bandVisual['color'];
|
|
|
|
|
+
|
|
|
|
|
+ if ($peerCount < 50) {
|
|
|
|
|
+ $peers['display_mode'] = 'masked';
|
|
|
|
|
+ $peers['sample_size'] = null;
|
|
|
|
|
+ $peers['peer_avg_score_rate'] = null;
|
|
|
|
|
+ $peers['percentile'] = null;
|
|
|
|
|
+ $peers['message'] = '已建立同群体参照,当前处于'.$band.'区间。';
|
|
|
|
|
+ } elseif ($peerCount > 200) {
|
|
|
|
|
+ $peers['display_mode'] = 'percentile_only';
|
|
|
|
|
+ $peers['sample_size'] = null;
|
|
|
|
|
+ $peers['peer_avg_score_rate'] = null;
|
|
|
|
|
+ $peers['percentile'] = null;
|
|
|
|
|
+ $peers['message'] = '和上百位同年级同类型学生相比,你当前处于'.$band.'水平。';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $peers['display_mode'] = 'detailed';
|
|
|
|
|
+ $peers['message'] = '同年级同类型参照中,你当前位于 '
|
|
|
|
|
+ .number_format($percentile, 1).'% 分位,群体均值 '
|
|
|
|
|
+ .number_format($peerAvg * 100, 1).'% 。';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: 构建同群体对比失败', ['error' => $e->getMessage()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'history' => $history,
|
|
|
|
|
+ 'peers' => $peers,
|
|
|
|
|
+ 'mastery' => [
|
|
|
|
|
+ 'current_average_mastery' => $currentMastery !== null ? round($currentMastery, 4) : null,
|
|
|
|
|
+ ],
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function difficultyRangeByLevel(?int $level): ?array
|
|
|
|
|
+ {
|
|
|
|
|
+ $map = [
|
|
|
|
|
+ 0 => ['min' => 0.00, 'max' => 0.10],
|
|
|
|
|
+ 1 => ['min' => 0.10, 'max' => 0.25],
|
|
|
|
|
+ 2 => ['min' => 0.25, 'max' => 0.50],
|
|
|
|
|
+ 3 => ['min' => 0.50, 'max' => 0.75],
|
|
|
|
|
+ 4 => ['min' => 0.75, 'max' => 1.00],
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if ($level === null || ! isset($map[$level])) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $map[$level];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function mapDifficultyValueToLevel(float $difficulty): int
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($difficulty < 0.10) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($difficulty < 0.25) {
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($difficulty < 0.50) {
|
|
|
|
|
+ return 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($difficulty < 0.75) {
|
|
|
|
|
+ return 3;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return 4;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function difficultyLevelLabel(?int $level, ?string $fallback = null): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $map = [
|
|
|
|
|
+ 0 => '0基础',
|
|
|
|
|
+ 1 => '筑基',
|
|
|
|
|
+ 2 => '提分',
|
|
|
|
|
+ 3 => '培优',
|
|
|
|
|
+ 4 => '竞赛',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if ($level !== null && isset($map[$level])) {
|
|
|
|
|
+ return $map[$level];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $fallback = trim((string) $fallback);
|
|
|
|
|
+
|
|
|
|
|
+ return $fallback !== '' ? $fallback : '未设置';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function resolveDeltaTrendLabel(float $delta): string
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($delta >= 0.05) {
|
|
|
|
|
+ return '显著提升';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($delta >= 0.02) {
|
|
|
|
|
+ return '小幅提升';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($delta > -0.02) {
|
|
|
|
|
+ return '基本持平';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($delta > -0.05) {
|
|
|
|
|
+ return '小幅回落';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return '明显回落';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function resolvePeerBand(float $percentile): string
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($percentile >= 75) {
|
|
|
|
|
+ return '领先';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($percentile >= 45) {
|
|
|
|
|
+ return '中上';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($percentile >= 25) {
|
|
|
|
|
+ return '中下';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return '待提升';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 综合当前表现、历史趋势、同群体位置,给出“整体水平”。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array{
|
|
|
|
|
+ * label:string,
|
|
|
|
|
+ * composite_score:float,
|
|
|
|
|
+ * current_score:float,
|
|
|
|
|
+ * history_score:float,
|
|
|
|
|
+ * peer_score:float,
|
|
|
|
|
+ * difficulty_adjust:float
|
|
|
|
|
+ * }
|
|
|
|
|
+ */
|
|
|
|
|
+ private function buildV3OverallCompositeEvaluation(
|
|
|
|
|
+ ?float $scoreRate,
|
|
|
|
|
+ ?float $avgMastery,
|
|
|
|
|
+ array $difficultyInsight,
|
|
|
|
|
+ array $comparisonInsight,
|
|
|
|
|
+ string $fallbackLabel
|
|
|
|
|
+ ): array {
|
|
|
|
|
+ $currentScoreRatePct = $scoreRate !== null ? max(0.0, min(100.0, $scoreRate * 100.0)) : 50.0;
|
|
|
|
|
+ $currentMasteryPct = $avgMastery !== null ? max(0.0, min(100.0, $avgMastery * 100.0)) : 50.0;
|
|
|
|
|
+ $currentScore = (0.7 * $currentScoreRatePct) + (0.3 * $currentMasteryPct);
|
|
|
|
|
+
|
|
|
|
|
+ $history = $comparisonInsight['history'] ?? [];
|
|
|
|
|
+ $historyScore = 60.0;
|
|
|
|
|
+ if (! empty($history['is_first_exam'])) {
|
|
|
|
|
+ $historyScore = 60.0;
|
|
|
|
|
+ } elseif (! empty($history['low_baseline_guard'])) {
|
|
|
|
|
+ $historyScore = 60.0;
|
|
|
|
|
+ } elseif (! empty($history['has_data']) && isset($history['delta_score_rate'])) {
|
|
|
|
|
+ $delta = (float) $history['delta_score_rate']; // ±1
|
|
|
|
|
+ $historyScore = 60.0 + (200.0 * $delta); // delta 0.1 => +20
|
|
|
|
|
+ $historyScore = max(0.0, min(100.0, $historyScore));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $peers = $comparisonInsight['peers'] ?? [];
|
|
|
|
|
+ $peerScore = 60.0;
|
|
|
|
|
+ if (! empty($peers['show_line'])) {
|
|
|
|
|
+ if (isset($peers['percentile']) && $peers['percentile'] !== null) {
|
|
|
|
|
+ $peerScore = max(0.0, min(100.0, (float) $peers['percentile']));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $band = (string) ($peers['band'] ?? '暂无');
|
|
|
|
|
+ $peerScore = match ($band) {
|
|
|
|
|
+ '领先' => 82.0,
|
|
|
|
|
+ '中上' => 66.0,
|
|
|
|
|
+ '中下' => 46.0,
|
|
|
|
|
+ '待提升' => 28.0,
|
|
|
|
|
+ default => 60.0,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $difficultyAdjust = 0.0;
|
|
|
|
|
+ $difficultyStatus = (string) ($difficultyInsight['status'] ?? '');
|
|
|
|
|
+ if ($difficultyStatus === '偏难') {
|
|
|
|
|
+ $difficultyAdjust = 5.0;
|
|
|
|
|
+ } elseif ($difficultyStatus === '偏易') {
|
|
|
|
|
+ $difficultyAdjust = -5.0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $composite = (0.50 * $currentScore) + (0.25 * $historyScore) + (0.25 * $peerScore) + $difficultyAdjust;
|
|
|
|
|
+ $composite = max(0.0, min(100.0, $composite));
|
|
|
|
|
+
|
|
|
|
|
+ // 等级标准统一:
|
|
|
|
|
+ // S: 90-100, A: 75-89, B: 60-74, C: 40-59, D: 0-39
|
|
|
|
|
+ $grade = match (true) {
|
|
|
|
|
+ $composite >= 90.0 => 'S',
|
|
|
|
|
+ $composite >= 75.0 => 'A',
|
|
|
|
|
+ $composite >= 60.0 => 'B',
|
|
|
|
|
+ $composite >= 40.0 => 'C',
|
|
|
|
|
+ default => 'D',
|
|
|
|
|
+ };
|
|
|
|
|
+ $label = match ($grade) {
|
|
|
|
|
+ 'S', 'A' => '优秀',
|
|
|
|
|
+ 'B' => '良好',
|
|
|
|
|
+ 'C' => '一般',
|
|
|
|
|
+ default => '需加强',
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if ($scoreRate === null && $avgMastery === null) {
|
|
|
|
|
+ $label = $fallbackLabel !== '' ? $fallbackLabel : '待评估';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'grade' => $grade,
|
|
|
|
|
+ 'label' => $label,
|
|
|
|
|
+ 'composite_score' => round($composite, 1),
|
|
|
|
|
+ 'current_score' => round($currentScore, 1),
|
|
|
|
|
+ 'history_score' => round($historyScore, 1),
|
|
|
|
|
+ 'peer_score' => round($peerScore, 1),
|
|
|
|
|
+ 'difficulty_adjust' => round($difficultyAdjust, 1),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 首次出报告时,按得分档位随机抽取鼓励文案(每档10条)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 档位标准统一:
|
|
|
|
|
+ * A: 90-100, B: 75-89, C: 60-74, D: 40-59, E: 0-39
|
|
|
|
|
+ */
|
|
|
|
|
+ private function pickFirstExamEncouragementMessageByScore(?float $scoreRate): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $score = 0.0;
|
|
|
|
|
+ if ($scoreRate !== null) {
|
|
|
|
|
+ $raw = (float) $scoreRate;
|
|
|
|
|
+ $score = $raw <= 1.0 ? ($raw * 100.0) : $raw;
|
|
|
|
|
+ $score = max(0.0, min(100.0, $score));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $bucket = match (true) {
|
|
|
|
|
+ $score >= 90.0 => 'A',
|
|
|
|
|
+ $score >= 75.0 => 'B',
|
|
|
|
|
+ $score >= 60.0 => 'C',
|
|
|
|
|
+ $score >= 40.0 => 'D',
|
|
|
|
|
+ default => 'E',
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ $messagesByBucket = [
|
|
|
|
|
+ 'A' => [
|
|
|
|
|
+ '这次开局很稳,说明你的基础和状态都在线。',
|
|
|
|
|
+ '第一次就拿到高分,后续保持节奏会更强。',
|
|
|
|
|
+ '你的学习方法是有效的,继续按这个路径推进。',
|
|
|
|
|
+ '这是一个很好的起点,接下来可以适度挑战难题。',
|
|
|
|
|
+ '成绩很亮眼,说明你已经具备较强的掌握能力。',
|
|
|
|
|
+ '你的投入有明显回报,继续保持就会持续领先。',
|
|
|
|
|
+ '开局高分值得肯定,下一步重点是稳定输出。',
|
|
|
|
|
+ '这次表现优秀,后续可以往“又快又准”再升级。',
|
|
|
|
|
+ '你已经在高水平区间,继续打磨细节会更出色。',
|
|
|
|
|
+ '这是非常有竞争力的起步,继续冲就对了。',
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'B' => [
|
|
|
|
|
+ '这个分数是很不错的起点,方向完全正确。',
|
|
|
|
|
+ '你已经进入良好区间,再补几处薄弱点就能上台阶。',
|
|
|
|
|
+ '开局表现可圈可点,继续练会更稳定。',
|
|
|
|
|
+ '说明你有扎实基础,后续提升空间也很清晰。',
|
|
|
|
|
+ '这次成绩不错,下一步就是把失分点逐个清掉。',
|
|
|
|
|
+ '起步良好,继续保持专注,进步会很快。',
|
|
|
|
|
+ '你已经具备不错的能力,差的是一点点细节打磨。',
|
|
|
|
|
+ '这个起点很健康,后续很有机会冲到更高档。',
|
|
|
|
|
+ '成绩说明你在正轨上,继续按计划推进就行。',
|
|
|
|
|
+ '这次发挥稳定,接下来把短板补齐会很明显。',
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'C' => [
|
|
|
|
|
+ '这是正常且可提升的起点,先稳住基础最关键。',
|
|
|
|
|
+ '你已经有一定掌握度,接下来重点是补薄弱模块。',
|
|
|
|
|
+ '这个分数段提升通常很快,方向对了就会涨。',
|
|
|
|
|
+ '开局在中位区间,不焦虑,持续练习就会突破。',
|
|
|
|
|
+ '先把常错题型吃透,你的分数会明显上来。',
|
|
|
|
|
+ '这次结果能帮我们精准定位问题,价值很大。',
|
|
|
|
|
+ '起点清晰、空间也清晰,后续提升可期待。',
|
|
|
|
|
+ '你的基础在,下一步要把稳定性做出来。',
|
|
|
|
|
+ '这个阶段最怕放弃,最值得坚持。',
|
|
|
|
|
+ '继续按节奏推进,很快就能看到上升曲线。',
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'D' => [
|
|
|
|
|
+ '第一次这个分数不代表上限,只代表当前起点。',
|
|
|
|
|
+ '现在最重要的是先建立信心,再逐步提分。',
|
|
|
|
|
+ '这次结果很有价值,能帮你更精准地补基础。',
|
|
|
|
|
+ '先把核心概念补牢,分数会先稳再升。',
|
|
|
|
|
+ '这个阶段提升潜力很大,方法对了进步会很快。',
|
|
|
|
|
+ '不用和别人比,先和昨天的自己比就很好。',
|
|
|
|
|
+ '先做对“会做的题”,再攻“有难度的题”。',
|
|
|
|
|
+ '你现在需要的是节奏和耐心,不是否定自己。',
|
|
|
|
|
+ '起步偏低很常见,持续练习就会逐渐反转。',
|
|
|
|
|
+ '只要不放弃,这个分段通常最容易拉开增幅。',
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'E' => [
|
|
|
|
|
+ '第一次分数偏低很正常,先把学习路径走顺。',
|
|
|
|
|
+ '这不是结论,只是起点,我们从基础一点点重建。',
|
|
|
|
|
+ '先把会做题做稳,信心会先回来。',
|
|
|
|
|
+ '现在最关键的是“稳基础、慢提速”。',
|
|
|
|
|
+ '低分并不定义能力,持续训练才会定义结果。',
|
|
|
|
|
+ '先把核心知识补齐,后续提升会很明显。',
|
|
|
|
|
+ '今天看到的是起点,不是终点。',
|
|
|
|
|
+ '你需要的是清晰步骤,不是压力。',
|
|
|
|
|
+ '每次进步一点点,累计起来会很惊人。',
|
|
|
|
|
+ '从现在开始,踏实走每一步,结果一定会变。',
|
|
|
|
|
+ ],
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $messages = $messagesByBucket[$bucket] ?? $messagesByBucket['C'];
|
|
|
|
|
+ $idx = random_int(0, count($messages) - 1);
|
|
|
|
|
+
|
|
|
|
|
+ return $messages[$idx];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 为同群体区间提供可视化图标与颜色。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array{icon:string,color:string}
|
|
|
|
|
+ */
|
|
|
|
|
+ private function resolvePeerBandVisual(string $band): array
|
|
|
|
|
+ {
|
|
|
|
|
+ return match ($band) {
|
|
|
|
|
+ '领先' => ['icon' => '▲', 'color' => '#16a34a'],
|
|
|
|
|
+ '中上' => ['icon' => '↗', 'color' => '#0ea5e9'],
|
|
|
|
|
+ '中下' => ['icon' => '↘', 'color' => '#f59e0b'],
|
|
|
|
|
+ '待提升' => ['icon' => '▼', 'color' => '#ef4444'],
|
|
|
|
|
+ default => ['icon' => '•', 'color' => '#64748b'],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 收集某学段根节点下的全量后代知识点(不含根节点本身)
|
|
* 收集某学段根节点下的全量后代知识点(不含根节点本身)
|
|
|
*/
|
|
*/
|
|
@@ -1749,6 +2313,7 @@ class ExamPdfExportService
|
|
|
'id' => $paper->paper_id,
|
|
'id' => $paper->paper_id,
|
|
|
'name' => $paper->paper_name,
|
|
'name' => $paper->paper_name,
|
|
|
'paper_type' => $paper->paper_type,
|
|
'paper_type' => $paper->paper_type,
|
|
|
|
|
+ 'difficulty_category' => $paper->difficulty_category,
|
|
|
'assemble_type_label' => $assembleTypeLabel,
|
|
'assemble_type_label' => $assembleTypeLabel,
|
|
|
'total_questions' => $paper->question_count,
|
|
'total_questions' => $paper->question_count,
|
|
|
'total_score' => $paper->total_score,
|
|
'total_score' => $paper->total_score,
|
|
@@ -1983,6 +2548,7 @@ class ExamPdfExportService
|
|
|
'question_type' => $normalizedType,
|
|
'question_type' => $normalizedType,
|
|
|
'knowledge_point' => $kpCode,
|
|
'knowledge_point' => $kpCode,
|
|
|
'knowledge_point_name' => $kpName,
|
|
'knowledge_point_name' => $kpName,
|
|
|
|
|
+ 'difficulty' => isset($question->difficulty) ? (float) $question->difficulty : null,
|
|
|
'score' => $question->score,
|
|
'score' => $question->score,
|
|
|
'answer' => $this->formatNewlines($answer), // 格式化换行
|
|
'answer' => $this->formatNewlines($answer), // 格式化换行
|
|
|
'solution' => $this->formatNewlines($solution), // 格式化换行
|
|
'solution' => $this->formatNewlines($solution), // 格式化换行
|